Company Research Agent — Source-Backed Company Briefings (LangGraph Pipeline + React UI)
Turns a company name into a structured, source-backed briefing. A FastAPI backend runs a LangGraph pipeline — four researchers (company, financial, industry, news), then collector, curator, enricher, grounding, briefing and editor — over Tavily search, while a React interface streams progress and exports the report as PDF. Works with OpenRouter, OpenAI or Gemini; MongoDB persistence optional.
Uploaded by TalentDelta Team Member (Scout) · via TalentDelta
build 1.0.0 · 3149.6 KB · published 2026-10-24 · 143 files
Licence: Apache-2.0
Scouted from github.com/guy-hartstein/company-research-agent at commit abbbc3a30f35d78afa9a1790053a18c0e60fe552 (2026-10-22). Licence Apache-2.0. Upstream credit: Guy Hartstein (guy-hartstein). static/demo.mp4 excluded as regenerable; every shipped file byte-identical to the pin apart from this record. Upstream code and licence stay upstream's.
Brought into the catalogue by TalentDelta Team Member (Scout).
What we like
A source-backed research pipeline in the open: four researchers — company, financial, industry, news — feeding six pipeline nodes (collector, curator, enricher, grounding, briefing, editor) over a LangGraph graph, with a React interface and a PDF export service shipped. The archive is clean and the README ships in six languages.
Potential improvements
The demo video the README promises is not in the archive. No identity or config file, so an installed agent starts with no persona.
Minimum requirements
A Tavily key, a MongoDB connection string, and one model key — OpenRouter, OpenAI or Gemini.
Security risks
Fetched web pages flow into the prompts, so treat retrieved content as untrusted input. Briefings and credentials live in your own MongoDB; secure it before use.
Tested: packaging, counts and credential sweep. Not tested: a live research run, which needs paid keys.
143 files · 3792.2 KB · previews are generated at approval and never executed
company-research-agent/.dockerignoretext · 0.5 KB
# Version control .git .gitignore .gitattributes # Environment files .env .env.* *.env # Python __pycache__/ *.py[cod] *.so .Python *.egg *.egg-info/ .eggs/ *.pyc .pytest_cache/ .coverage htmlcov/ .tox/ .venv/ venv/ # Node.js ui/node_modules/ ui/.npm ui/npm-debug.log* ui/yarn-debug.log* ui/yarn-error.log* ui/dist/ # IDE .idea/ .vscode/ *.swp *.swo # OS .DS_Store Thumbs.db # Project specific reports/* .langgraph/ .elasticbeanstalk/ README.md LICENSE *.md *.logcompany-research-agent/.env.exampletext · 0.3 KB
TAVILY_API_KEY= # OpenRouter (recommended): one key for any model. The model can be chosen in the app's Settings. OPENROUTER_API_KEY= # Optional default model for OpenRouter (defaults to google/gemini-3.8-flash) OPENROUTER_MODEL= # Direct provider keys, only used when no OpenRouter key is configured OPENAI_API_KEY= GEMINI_API_KEY=company-research-agent/.github/ISSUE_TEMPLATE/bug_report.mdmarkdown · 0.8 KB
name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: ''
Describe the bug A clear and concise description of what the bug is.
To Reproduce Steps to reproduce the behavior:
- Go to '...'
- Click on '....'
- Scroll down to '....'
- See error
Expected behavior A clear and concise description of what you expected to happen.
Screenshots If applicable, add screenshots to help explain your problem.
Desktop (please complete the following information):
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
Smartphone (please complete the following information):
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
Additional context Add any other context about the problem here.
company-research-agent/.github/ISSUE_TEMPLATE/feature_request.mdmarkdown · 0.6 KB
name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: ''
Is your feature request related to a problem? Please describe. A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
Describe the solution you'd like A clear and concise description of what you want to happen.
Describe alternatives you've considered A clear and concise description of any alternative solutions or features you've considered.
Additional context Add any other context or screenshots about the feature request here.
company-research-agent/.github/dependabot.ymlcode · 0.5 KB
# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly"company-research-agent/.gitignoretext · 0.3 KB
# Environment and secrets .env reports/* # Python __pycache__/ *.py[cod] *.egg-info/ # Virtual Environment .venv/ # IDE and OS .vscode/ .DS_Store # Frontend node_modules/ # LangGraph .langgraph/ # Elastic Beanstalk Files .elasticbeanstalk/* !.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.global.yml Procfile .vercel .ebextensionscompany-research-agent/Dockerfiletext · 1.1 KB
# Stage 1: Build Frontend FROM node:20-slim AS frontend-builder WORKDIR /app/ui COPY ui/package*.json ./ RUN npm install COPY ui/ ./ RUN npm run build # Stage 2: Build Backend FROM python:3.11-slim AS backend-builder WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Stage 3: Final Image FROM python:3.11-slim WORKDIR /app # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* # Copy backend COPY --from=backend-builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ COPY backend/ ./backend/ COPY application.py . # Copy frontend build COPY --from=frontend-builder /app/ui/dist/ ./ui/dist/ # Create reports directory RUN mkdir -p reports # Set environment variables ENV PYTHONUNBUFFERED=1 ENV PORT=8000 # Expose the port EXPOSE 8000 # Create a non-root user RUN useradd -m -u 1000 appuser RUN chown -R appuser:appuser /app USER appuser # Start command CMD ["python", "-m", "uvicorn", "application:app", "--host", "0.0.0.0", "--port", "8000"]company-research-agent/LICENSEtext · 11.1 KB
Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2026 Guy Hartstein Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.company-research-agent/README.es.mdmarkdown · 15.1 KB
Investigador de Empresas 🔍
Una herramienta multi-agente que genera informes de investigación exhaustivos sobre empresas. La plataforma utiliza un sistema de agentes de IA para recopilar, seleccionar y sintetizar información sobre cualquier empresa.
✨¡Pruébalo en línea! https://companyresearcher.tavily.com ✨
https://github.com/user-attachments/assets/0e373146-26a7-4391-b973-224ded3182a9
Características
- Investigación Multi-Fuente: Recopila datos de diversas fuentes, incluyendo sitios web de empresas, artículos de noticias, informes financieros y análisis sectoriales
- Filtrado de Contenido Impulsado por IA: Utiliza la puntuación de relevancia de Tavily para la selección de contenido
- Procesamiento Asíncrono: Arquitectura eficiente basada en polling para rastrear el progreso de la investigación
- Selección Flexible de Modelos: Usa cualquier modelo disponible en OpenRouter, elegido desde el panel de Configuración de la aplicación, o claves directas de OpenAI/Gemini como alternativa
- Frontend Moderno en React: Interfaz de usuario receptiva con seguimiento de progreso y opciones de descarga
- Arquitectura Modular: Construido utilizando un sistema de nodos de investigación y procesamiento especializados
Marco de Agentes
Sistema de Investigación
La plataforma sigue un marco basado en agentes con nodos especializados que procesan datos secuencialmente:
-
Nodos de Investigación:
CompanyAnalyzer: Investiga información básica del negocioIndustryAnalyzer: Analiza posición de mercado y tendenciasFinancialAnalyst: Recopila métricas financieras y datos de rendimientoNewsScanner: Recopila noticias y desarrollos recientes
-
Nodos de Procesamiento:
Collector: Agrega datos de investigación de todos los analizadoresCurator: Implementa filtrado de contenido y puntuación de relevanciaBriefing: Genera resúmenes específicos por categoría utilizando el modelo seleccionadoEditor: Compila y formatea los resúmenes en un informe final utilizando el modelo seleccionado
Arquitectura de Generación de Contenido
Todos los pasos con LLM (generación de consultas en los investigadores,
BriefingyEditor) utilizan un único modelo. El proveedor se elige enbackend/llm.py:-
OpenRouter (recomendado):
- Con una clave de OpenRouter (
OPENROUTER_API_KEYen el.envdel backend, o pegada en el panel de Configuración de la aplicación), todos los pasos usan el modelo de OpenRouter seleccionado en Configuración - Modelo predeterminado:
google/gemini-3.8-flash, configurable conOPENROUTER_MODEL
- Con una clave de OpenRouter (
-
Claves directas (alternativa):
- Sin clave de OpenRouter, el backend utiliza
OPENAI_API_KEY(modelos GPT) - Si
GEMINI_API_KEYestá configurada, se usa Gemini 2.5 Flash solo para los resúmenes; la clave de Gemini ya no es obligatoria
- Sin clave de OpenRouter, el backend utiliza
Sistema de Selección de Contenido
La plataforma utiliza un sistema de filtrado de contenido en
curator.py:-
Puntuación de Relevancia:
- Los documentos son puntuados por la búsqueda potenciada por IA de Tavily
- Se requiere un umbral mínimo (predeterminado 0.4) para proceder
- Las puntuaciones reflejan la relevancia para la consulta de investigación específica
- Puntuaciones más altas indican mejores coincidencias con la intención de la investigación
-
Procesamiento de Documentos:
- El contenido se normaliza y limpia
- Las URLs se desduplicaron y estandarizaron
- Los documentos se ordenan por puntuaciones de relevancia
- La investigación se ejecuta de forma asíncrona en segundo plano
Arquitectura del Backend
La plataforma implementa un sistema de comunicación simple basado en polling:
-
Implementación Backend:
- Utiliza FastAPI con soporte asíncrono
- Las tareas de investigación se ejecutan en segundo plano
- Los resultados se almacenan y acceden mediante endpoints REST
- Seguimiento simple del estado del trabajo
-
Integración Frontend:
- El frontend React envía solicitudes de investigación
- Recibe job_id para seguimiento
- Realiza polling al endpoint
/research/{job_id}/report - Muestra el informe final cuando está completo
-
Endpoints de la API:
POST /research: Enviar nueva solicitud de investigación (acepta opcionalmenteopenrouter_api_keyymodel)GET /models: Listar los modelos de texto disponibles en OpenRouter (en caché durante 1 hora)GET /config: Indicar qué claves de LLM tiene configuradas el servidor y el modelo predeterminadoGET /research/{job_id}/report: Polling para informe completadoPOST /generate-pdf: Generar PDF del contenido del informe
Instalación
Instalación Rápida (Recomendada)
La forma más sencilla de comenzar es utilizando el script de instalación, que detecta automáticamente y usa
uvpara una instalación más rápida de paquetes Python cuando está disponible:- Clonar el repositorio:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- Hacer que el script de instalación sea ejecutable y ejecutarlo:
chmod +x setup.sh ./setup.shEl script de instalación hará lo siguiente:
- Detectar y usar
uvpara una instalación más rápida de paquetes Python (si está disponible) - Verificar las versiones requeridas de Python y Node.js
- Opcionalmente crear un entorno virtual de Python (recomendado)
- Instalar todas las dependencias (Python y Node.js)
- Guiarte a través de la configuración de tus variables de entorno (te pedirá tu clave de Tavily y tu clave de OpenRouter; solo pedirá claves de OpenAI/Gemini si omites la de OpenRouter)
- Opcionalmente iniciar los servidores de backend y frontend
💡 Consejo Pro: Instala uv para una instalación significativamente más rápida de paquetes Python:
curl -LsSf https://astral.sh/uv/install.sh | shNecesitarás tener listas las siguientes claves API:
- Clave API de Tavily
- Clave API de OpenRouter (recomendada; también puedes pegarla más tarde en Configuración)
- Clave API de OpenAI (solo si no usas OpenRouter)
- Clave API de Google Gemini (opcional)
- Clave API de Google Maps (opcional, solo para el autocompletado de ubicaciones)
- URI de MongoDB (opcional)
Instalación Manual
Si prefieres realizar la instalación manualmente, sigue estos pasos:
- Clonar el repositorio:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- Instalar dependencias de backend:
# Opcional: Crear y activar entorno virtual # Con uv (más rápido - recomendado si está disponible): uv venv .venv source .venv/bin/activate # O con Python estándar: # python -m venv .venv # source .venv/bin/activate # Instalar dependencias de Python # Con uv (más rápido): uv pip install -r requirements.txt # O con pip: # pip install -r requirements.txt- Instalar dependencias de frontend:
cd ui npm install- Configurar Variables de Entorno:
Este proyecto requiere dos archivos
.envseparados para el backend y el frontend.Configuración del Backend:
Crea un archivo
.enven el directorio raíz del proyecto y añade tus claves API del backend:TAVILY_API_KEY=tu_clave_tavily # Recomendado: una sola clave para todos los modelos (o pega una clave en la Configuración de la aplicación) OPENROUTER_API_KEY=tu_clave_openrouter # Modelo predeterminado opcional; los usuarios pueden elegir otro en Configuración # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # Alternativa a OpenRouter: claves directas de los proveedores # OPENAI_API_KEY=tu_clave_openai # GEMINI_API_KEY=tu_clave_gemini # Opcional: Habilitar persistencia en MongoDB # MONGODB_URI=tu_cadena_de_conexion_mongodbConfiguración del Frontend:
Crea un archivo
.envdentro del directorioui. Puedes copiar primero el archivo de ejemplo:cp ui/.env.development.example ui/.envLuego, abre
ui/.envy añade tus variables de entorno del frontend:VITE_API_URL=http://localhost:8000 VITE_GOOGLE_MAPS_API_KEY=tu_clave_google_maps_aquiInstalación con Docker
La aplicación puede ejecutarse utilizando Docker y Docker Compose:
- Clonar el repositorio:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- Configurar Variables de Entorno:
La configuración de Docker utiliza dos archivos
.envseparados.Configuración del Backend:
Crea un archivo
.enven el directorio raíz del proyecto con tus claves API del backend:TAVILY_API_KEY=tu_clave_tavily # Recomendado: una sola clave para todos los modelos (o pega una clave en la Configuración de la aplicación) OPENROUTER_API_KEY=tu_clave_openrouter # Modelo predeterminado opcional; los usuarios pueden elegir otro en Configuración # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # Alternativa a OpenRouter: claves directas de los proveedores # OPENAI_API_KEY=tu_clave_openai # GEMINI_API_KEY=tu_clave_gemini # Opcional: Habilitar persistencia en MongoDB # MONGODB_URI=tu_cadena_de_conexion_mongodbConfiguración del Frontend:
Crea un archivo
.envdentro del directorioui. Puedes copiar primero el archivo de ejemplo:cp ui/.env.development.example ui/.envLuego, abre
ui/.envy añade tus variables de entorno del frontend:VITE_API_URL=http://localhost:8000 VITE_GOOGLE_MAPS_API_KEY=tu_clave_google_maps_aqui- Construir e iniciar los contenedores:
docker compose up --buildEsto iniciará los servicios de backend y frontend:
- La API de backend estará disponible en
http://localhost:8000 - El frontend estará disponible en
http://localhost:5174
Para detener los servicios:
docker compose downNota: Al actualizar las variables de entorno en
.env, necesitarás reiniciar los contenedores:docker compose down && docker compose upElegir un Modelo
Haz clic en el botón del modelo en la esquina superior derecha de la aplicación para abrir Configuración. Allí puedes pegar una clave de OpenRouter (se guarda solo en tu navegador y se envía con cada solicitud de investigación) y buscar o filtrar el catálogo de modelos de OpenRouter por proveedor, con sus logotipos. El modelo seleccionado se usa en todos los pasos de la investigación. Si no hay clave de OpenRouter disponible, el backend recurre a
OPENAI_API_KEY(y a Gemini para los resúmenes siGEMINI_API_KEYestá configurada).Ejecutando la Aplicación
- Iniciar el servidor de backend (elige una opción):
# Opción 1: Módulo Python Directo python -m application.py # Opción 2: FastAPI con Uvicorn uvicorn application:app --reload --port 8000- En una nueva terminal, iniciar el frontend:
cd ui npm run dev- Acceder a la aplicación en
http://localhost:5173
Uso
Desarrollo Local
-
Iniciar el servidor de backend (elige una opción):
Opción 1: Módulo Python Directo
python -m application.pyOpción 2: FastAPI con Uvicorn
# Instalar uvicorn si aún no está instalado # Con uv (más rápido): uv pip install uvicorn # O con pip: # pip install uvicorn # Ejecutar la aplicación FastAPI con recarga automática uvicorn application:app --reload --port 8000El backend estará disponible en:
- Punto de conexión API:
http://localhost:8000
- Punto de conexión API:
-
Iniciar el servidor de desarrollo del frontend:
cd ui npm run dev -
Acceder a la aplicación en
http://localhost:5173
⚡ Nota de Rendimiento: Si usaste
uvdurante la instalación, te beneficiarás de una instalación de paquetes y resolución de dependencias significativamente más rápida.uves un gestor de paquetes Python moderno escrito en Rust que puede ser 10-100x más rápido que pip.Opciones de Despliegue
La aplicación puede desplegarse en varias plataformas en la nube. Aquí hay algunas opciones comunes:
- Docker: La aplicación incluye un Dockerfile para despliegue en contenedores
- Heroku: Despliegue directamente desde GitHub con el buildpack de Python
- Google Cloud Run: Adecuado para despliegue en contenedores con escalado automático
Elige la plataforma que mejor se adapte a tus necesidades. La aplicación es independiente de la plataforma y puede alojarse en cualquier lugar que admita aplicaciones web Python.
Contribuir
- Haz un fork del repositorio
- Crea una rama de características (
git checkout -b feature/caracteristica-increible) - Haz commit de tus cambios (
git commit -m 'Añadir característica increíble') - Haz push a la rama (
git push origin feature/caracteristica-increible) - Abre un Pull Request
Licencia
Este proyecto está licenciado bajo la Licencia Apache 2.0 - consulta el archivo LICENSE para más detalles.
Agradecimientos
- Tavily por la API de investigación
- Todas las demás bibliotecas de código abierto y sus contribuyentes
Star History
company-research-agent/README.fr.mdmarkdown · 14.3 KB
Agent de Recherche d'Entreprise 🔍
Un outil multi-agents qui génère des rapports de recherche d'entreprise complets. La plateforme utilise un pipeline d'agents IA pour collecter, organiser et synthétiser des informations sur n'importe quelle entreprise.
✨Essayez-le en ligne ! https://companyresearcher.tavily.com ✨
https://github.com/user-attachments/assets/0e373146-26a7-4391-b973-224ded3182a9
Fonctionnalités
- Recherche Multi-Sources : Récupère des données de diverses sources, y compris les sites web d'entreprise, articles de presse, rapports financiers et analyses sectorielles
- Filtrage de contenu par IA : Utilise le score de pertinence de Tavily pour la curation du contenu
- Traitement Asynchrone : Architecture efficace basée sur le polling pour suivre la progression de la recherche
- Choix flexible du modèle : Utilisez n'importe quel modèle OpenRouter sélectionné dans les Paramètres, ou des clés OpenAI/Gemini directes en solution de repli
- Frontend React moderne : Interface réactive avec suivi de progression et options de téléchargement
- Architecture modulaire : Construite autour d'un pipeline de nœuds spécialisés de recherche et de traitement
Cadre Agentique
Pipeline de Recherche
La plateforme suit un cadre agentique avec des nœuds spécialisés qui traitent les données de manière séquentielle :
-
Nœuds de Recherche :
CompanyAnalyzer: Recherche les informations principales sur l'entrepriseIndustryAnalyzer: Analyse la position sur le marché et les tendancesFinancialAnalyst: Récupère les indicateurs financiers et les données de performanceNewsScanner: Collecte les actualités et développements récents
-
Nœuds de Traitement :
Collector: Agrège les données de recherche de tous les analyseursCurator: Met en œuvre le filtrage de contenu et le scoring de pertinenceBriefing: Génère des synthèses par catégorie à l'aide du modèle sélectionnéEditor: Compile et met en forme les synthèses dans un rapport final avec le modèle sélectionné
Architecture de Génération de Contenu
Toutes les étapes LLM (génération des requêtes par les chercheurs,
BriefingetEditor) utilisent un seul et même modèle. Le fournisseur est choisi dansbackend/llm.py:-
OpenRouter (recommandé) :
- Activé lorsqu'une clé OpenRouter est disponible (
OPENROUTER_API_KEYdans le.envdu backend, ou collée dans le panneau Paramètres de l'application) - Chaque étape utilise le modèle OpenRouter sélectionné dans les Paramètres
- Modèle par défaut :
google/gemini-3.8-flash, modifiable viaOPENROUTER_MODEL
- Activé lorsqu'une clé OpenRouter est disponible (
-
Clés directes (repli) :
- Sans clé OpenRouter, le backend utilise
OPENAI_API_KEY(modèles GPT) - Gemini 2.5 Flash est utilisé pour les synthèses uniquement si
GEMINI_API_KEYest défini ; une clé Gemini n'est plus obligatoire
- Sans clé OpenRouter, le backend utilise
Système de Curation de Contenu
La plateforme utilise un système de filtrage de contenu dans
curator.py:-
Scoring de Pertinence :
- Les documents sont scorés par la recherche IA de Tavily
- Un seuil minimum (par défaut 0,4) est requis pour continuer
- Les scores reflètent la pertinence par rapport à la requête de recherche
- Un score élevé indique une meilleure correspondance avec l'intention de recherche
-
Traitement des Documents :
- Le contenu est normalisé et nettoyé
- Les URLs sont dédupliquées et standardisées
- Les documents sont triés par score de pertinence
- La recherche s'exécute de manière asynchrone en arrière-plan
Architecture Backend
La plateforme implémente un système de communication simple basé sur le polling :
-
Implémentation Backend :
- Utilise FastAPI avec support asynchrone
- Les tâches de recherche s'exécutent en arrière-plan
- Les résultats sont stockés et accessibles via des endpoints REST
- Suivi simple de l'état des tâches
-
Intégration Frontend :
- Le frontend React soumet des demandes de recherche
- Reçoit un job_id pour le suivi
- Effectue un polling sur l'endpoint
/research/{job_id}/report - Affiche le rapport final une fois terminé
-
Endpoints de l'API :
POST /research: Soumettre une nouvelle demande de recherche (accepte en optionopenrouter_api_keyetmodel)GET /research/{job_id}/report: Polling pour le rapport terminéPOST /generate-pdf: Générer un PDF du contenu du rapportGET /models: Lister les modèles texte OpenRouter (mis en cache pendant 1 heure)GET /config: Indiquer quelles clés LLM le serveur possède, ainsi que le modèle par défaut
Configuration
Configuration Rapide (Recommandée)
La façon la plus simple de commencer est d'utiliser le script de configuration, qui détecte automatiquement et utilise
uvpour une installation plus rapide des paquets Python lorsqu'il est disponible :- Clonez le dépôt :
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- Rendez le script de configuration exécutable et lancez-le :
chmod +x setup.sh ./setup.shLe script de configuration va :
- Détecter et utiliser
uvpour une installation plus rapide des paquets Python (si disponible) - Vérifier les versions requises de Python et Node.js
- Créer éventuellement un environnement virtuel Python (recommandé)
- Installer toutes les dépendances (Python et Node.js)
- Vous guider dans la configuration de vos variables d'environnement
- Vous demander votre clé OpenRouter (recommandée, peut être ignorée ; les clés OpenAI/Gemini ne sont demandées que si vous l'ignorez)
- Démarrer éventuellement les serveurs backend et frontend
💡 Conseil Pro : Installez uv pour une installation significativement plus rapide des paquets Python :
curl -LsSf https://astral.sh/uv/install.sh | shVous aurez besoin des clés API suivantes :
- Clé API Tavily
- Clé API OpenRouter (recommandée — vous pouvez aussi la coller plus tard dans les Paramètres)
- Clé API OpenAI (uniquement si vous n'utilisez pas OpenRouter)
- Clé API Google Gemini (optionnel)
- Clé API Google Maps (optionnel, uniquement pour l'autocomplétion des lieux)
- URI MongoDB (optionnel)
Configuration Manuelle
Si vous préférez configurer manuellement, suivez ces étapes :
- Clonez le dépôt :
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- Installez les dépendances backend :
# Optionnel : Créez et activez un environnement virtuel # Avec uv (plus rapide - recommandé si disponible) : uv venv .venv source .venv/bin/activate # Ou avec Python standard : # python -m venv .venv # source .venv/bin/activate # Installez les dépendances Python # Avec uv (plus rapide) : uv pip install -r requirements.txt # Ou avec pip : # pip install -r requirements.txt- Installez les dépendances frontend :
cd ui npm install- Configuration des Variables d'Environnement :
Ce projet nécessite deux fichiers
.envséparés pour le backend et le frontend.Configuration Backend :
Créez un fichier
.envdans le répertoire racine du projet et ajoutez vos clés API backend :TAVILY_API_KEY=votre_clé_tavily # Recommandé : une seule clé pour tous les modèles (ou collez une clé dans les Paramètres de l'application) OPENROUTER_API_KEY=votre_clé_openrouter # Modèle par défaut optionnel ; les utilisateurs peuvent toujours en choisir un autre dans les Paramètres # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # Alternative à OpenRouter : clés directes des fournisseurs # OPENAI_API_KEY=votre_clé_openai # GEMINI_API_KEY=votre_clé_gemini # Optionnel : Activez la persistance MongoDB # MONGODB_URI=votre_chaîne_de_connexion_mongodbConfiguration Frontend :
Créez un fichier
.envdans le répertoireui. Vous pouvez d'abord copier le fichier d'exemple :cp ui/.env.development.example ui/.envPuis, ouvrez
ui/.envet ajoutez vos variables d'environnement frontend :VITE_API_URL=http://localhost:8000 VITE_GOOGLE_MAPS_API_KEY=votre_clé_google_maps_iciConfiguration Docker
L'application peut être exécutée à l'aide de Docker et Docker Compose :
- Clonez le dépôt :
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- Configuration des Variables d'Environnement :
La configuration Docker utilise deux fichiers
.envséparés.Configuration Backend :
Créez un fichier
.envdans le répertoire racine du projet et ajoutez vos clés API backend :TAVILY_API_KEY=votre_clé_tavily # Recommandé : une seule clé pour tous les modèles (ou collez une clé dans les Paramètres de l'application) OPENROUTER_API_KEY=votre_clé_openrouter # Modèle par défaut optionnel ; les utilisateurs peuvent toujours en choisir un autre dans les Paramètres # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # Alternative à OpenRouter : clés directes des fournisseurs # OPENAI_API_KEY=votre_clé_openai # GEMINI_API_KEY=votre_clé_gemini # Optionnel : Activez la persistance MongoDB # MONGODB_URI=votre_chaîne_de_connexion_mongodbConfiguration Frontend :
Créez un fichier
.envdans le répertoireui. Vous pouvez d'abord copier le fichier d'exemple :cp ui/.env.development.example ui/.envPuis, ouvrez
ui/.envet ajoutez vos variables d'environnement frontend :VITE_API_URL=http://localhost:8000 VITE_GOOGLE_MAPS_API_KEY=votre_clé_google_maps_ici- Construisez et démarrez les conteneurs :
docker compose up --buildCela démarrera les services backend et frontend :
- L'API backend sera disponible sur
http://localhost:8000 - Le frontend sera disponible sur
http://localhost:5174
Pour arrêter les services :
docker compose downRemarque : Lors de la mise à jour des variables d'environnement dans
.env, vous devrez redémarrer les conteneurs :docker compose down && docker compose upExécution de l'Application
- Démarrez le serveur backend (choisissez une option) :
# Option 1 : Module Python Direct python -m application.py # Option 2 : FastAPI avec Uvicorn uvicorn application:app --reload --port 8000- Dans un nouveau terminal, démarrez le frontend :
cd ui npm run dev- Accédez à l'application sur
http://localhost:5173
Utilisation
Choisir un modèle
Cliquez sur le bouton du modèle en haut à droite de l'application pour ouvrir les Paramètres. Vous pouvez y coller une clé OpenRouter (stockée uniquement dans votre navigateur et envoyée avec chaque demande de recherche) et rechercher ou filtrer le catalogue complet des modèles OpenRouter par fournisseur. Le modèle sélectionné est utilisé pour toutes les étapes du pipeline de recherche. Sans clé OpenRouter, le backend se rabat sur
OPENAI_API_KEY(avec Gemini pour les synthèses siGEMINI_API_KEYest défini).Développement Local
-
Démarrez le serveur backend (choisissez une option) :
Option 1 : Module Python Direct
python -m application.pyOption 2 : FastAPI avec Uvicorn
# Installez uvicorn si ce n'est pas déjà fait # Avec uv (plus rapide) : uv pip install uvicorn # Ou avec pip : # pip install uvicorn # Exécutez l'application FastAPI avec rechargement à chaud uvicorn application:app --reload --port 8000Le backend sera disponible sur :
- Point d'accès API :
http://localhost:8000
- Point d'accès API :
-
Démarrez le serveur de développement frontend :
cd ui npm run dev -
Accédez à l'application sur
http://localhost:5173
⚡ Note de Performance : Si vous avez utilisé
uvlors de l'installation, vous bénéficierez d'une installation de paquets et d'une résolution de dépendances significativement plus rapides.uvest un gestionnaire de paquets Python moderne écrit en Rust qui peut être 10 à 100 fois plus rapide que pip.Star History
Licence
Ce projet est distribué sous la licence Apache 2.0 - consultez le fichier LICENSE pour plus de détails.
company-research-agent/README.jp.mdmarkdown · 16.7 KB
企業調査エージェント 🔍
包括的な企業調査レポートを生成するマルチエージェントツール。このプラットフォームは、AIエージェントのパイプラインを使用して、あらゆる企業に関する情報を収集、整理、統合します。
✨オンラインで試してみてください! https://companyresearcher.tavily.com ✨
https://github.com/user-attachments/assets/0e373146-26a7-4391-b973-224ded3182a9
機能
- マルチソース調査:企業ウェブサイト、ニュース記事、財務報告書、業界分析など、様々なソースからデータを取得
- AIによるコンテンツフィルタリング:Tavilyの関連性スコアを使用したコンテンツキュレーション
- 非同期処理:調査の進捗を追跡するための効率的なポーリングベースアーキテクチャ
- 柔軟なモデル選択:設定パネルで選んだOpenRouterの任意のモデルを使用可能。フォールバックとしてOpenAI/Geminiの直接キーにも対応
- モダンなReactフロントエンド:進捗追跡とダウンロードオプションを備えたレスポンシブインターフェース
- モジュラーアーキテクチャ:専門的な調査・処理ノードのパイプラインを中心に構築
エージェントフレームワーク
調査パイプライン
このプラットフォームは、データを順次処理する専門ノードを持つエージェントフレームワークに従います:
-
調査ノード:
CompanyAnalyzer:主要な企業情報を調査IndustryAnalyzer:市場ポジションとトレンドを分析FinancialAnalyst:財務指標とパフォーマンスデータを取得NewsScanner:最新のニュースと動向を収集
-
処理ノード:
Collector:すべてのアナライザーから調査データを集約Curator:コンテンツフィルタリングと関連性スコアリングを実装Briefing:選択したモデルを使用してカテゴリ別の要約を生成Editor:選択したモデルで要約を最終レポートにコンパイル・書式設定
コンテンツ生成アーキテクチャ
すべてのLLMステップ(調査ノードでのクエリ生成、
Briefing、Editor)は1つのモデルを使用します。プロバイダーの選択はbackend/llm.pyで行われます:-
OpenRouter(推奨):
- OpenRouterキー(バックエンド
.envのOPENROUTER_API_KEY、またはアプリの設定パネルで貼り付けたキー)がある場合、すべてのステップで設定パネルで選択したOpenRouterモデルを使用 - デフォルトモデルは
google/gemini-3.8-flashで、OPENROUTER_MODELで変更可能
- OpenRouterキー(バックエンド
-
フォールバック:
- OpenRouterキーがない場合は
OPENAI_API_KEY(GPTモデル)を使用 GEMINI_API_KEYが設定されている場合のみ、要約生成にGemini 2.5 Flashを使用(Geminiキーは必須ではありません)
- OpenRouterキーがない場合は
コンテンツキュレーションシステム
このプラットフォームは
curator.pyでコンテンツフィルタリングシステムを使用します:-
関連性スコアリング:
- 文書はTavilyのAI検索によってスコア付けされます
- 継続するには最小閾値(デフォルト0.4)が必要
- スコアは検索クエリとの関連性を反映
- 高スコアは検索意図とのより良い一致を示す
-
文書処理:
- コンテンツは正規化・クリーニングされます
- URLは重複除去・標準化されます
- 文書は関連性スコアでソートされます
- 調査はバックグラウンドで非同期実行されます
バックエンドアーキテクチャ
プラットフォームはポーリングベースのシンプルな通信システムを実装します:
-
バックエンド実装:
- 非同期サポート付きのFastAPIを使用
- 調査タスクはバックグラウンドで実行
- 結果はRESTエンドポイント経由で保存・アクセス
- シンプルなジョブステータス追跡
-
フロントエンド統合:
- Reactフロントエンドが調査リクエストを送信
- 追跡用のjob_idを受信
/research/{job_id}/reportエンドポイントをポーリング- 完了時に最終レポートを表示
-
APIエンドポイント:
POST /research:新しい調査リクエストの送信(オプションでopenrouter_api_keyとmodelを指定可能)GET /models:OpenRouterで利用可能なテキストモデルの一覧(1時間キャッシュ)GET /config:サーバーに設定済みのLLMキーとデフォルトモデルを返すGET /research/{job_id}/report:完了したレポートのポーリングPOST /generate-pdf:レポートコンテンツからPDFを生成
セットアップ
クイックセットアップ(推奨)
最も簡単な開始方法はセットアップスクリプトを使用することです。このスクリプトは自動的に
uvを検出し、利用可能な場合はより高速なPythonパッケージインストールに使用します:- リポジトリをクローン:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- セットアップスクリプトを実行可能にして実行:
chmod +x setup.sh ./setup.shセットアップスクリプトは以下を行います:
uvを検出してより高速なPythonパッケージインストールに使用(利用可能な場合)- 必要なPythonとNode.jsのバージョンを確認
- Python仮想環境を作成(推奨)
- すべての依存関係をインストール(PythonとNode.js)
- 環境変数の設定をガイド(TavilyキーとOpenRouterキーを入力。OpenRouterキーをスキップした場合のみOpenAI/Geminiキーを確認)
- バックエンドとフロントエンドサーバーを起動(オプション)
💡 プロのヒント: uvをインストールすることで、Pythonパッケージの大幅に高速なインストールが可能になります:
curl -LsSf https://astral.sh/uv/install.sh | sh以下のAPIキーが必要です:
- Tavily APIキー
- OpenRouter APIキー(推奨。後からアプリの設定パネルで貼り付けることも可能)
- OpenAI APIキー(OpenRouterを使用しない場合のみ)
- Google Gemini APIキー(オプション)
- Google Maps APIキー(オプション。所在地の自動補完にのみ使用)
- MongoDB URI(オプション)
手動セットアップ
手動でセットアップしたい場合は、以下の手順に従ってください:
- リポジトリをクローン:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- バックエンド依存関係をインストール:
# オプション:仮想環境を作成・アクティベート # uv使用(より高速 - 利用可能な場合は推奨): uv venv .venv source .venv/bin/activate # または標準Python: # python -m venv .venv # source .venv/bin/activate # Python依存関係をインストール # uv使用(より高速): uv pip install -r requirements.txt # またはpip使用: # pip install -r requirements.txt- フロントエンド依存関係をインストール:
cd ui npm install- 環境変数の設定:
このプロジェクトはバックエンドとフロントエンド用に2つの個別の
.envファイルが必要です。バックエンド設定:
プロジェクトのルートディレクトリに
.envファイルを作成し、バックエンドAPIキーを追加します:TAVILY_API_KEY=your_tavily_key # 推奨:すべてのモデルに使える1つのキー(代わりにアプリの設定パネルでキーを貼り付けることも可能) OPENROUTER_API_KEY=your_openrouter_key # オプションのデフォルトモデル。ユーザーは設定パネルで別のモデルを選択可能 # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # OpenRouterの代替:各プロバイダーの直接キー # OPENAI_API_KEY=your_openai_key # GEMINI_API_KEY=your_gemini_key # オプション:MongoDB永続化を有効化 # MONGODB_URI=your_mongodb_connection_stringフロントエンド設定:
uiディレクトリ内に.envファイルを作成します。最初にサンプルファイルをコピーできます:cp ui/.env.development.example ui/.env次に、
ui/.envを開いてフロントエンド環境変数を追加します:VITE_API_URL=http://localhost:8000 VITE_GOOGLE_MAPS_API_KEY=your_google_maps_api_key_hereDockerセットアップ
アプリケーションはDockerとDocker Composeを使用して実行できます:
- リポジトリをクローン:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- 環境変数の設定:
Docker設定は2つの個別の
.envファイルを使用します。バックエンド設定:
プロジェクトのルートディレクトリに
.envファイルを作成し、バックエンドAPIキーを追加します:TAVILY_API_KEY=your_tavily_key # 推奨:すべてのモデルに使える1つのキー(代わりにアプリの設定パネルでキーを貼り付けることも可能) OPENROUTER_API_KEY=your_openrouter_key # オプションのデフォルトモデル。ユーザーは設定パネルで別のモデルを選択可能 # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # OpenRouterの代替:各プロバイダーの直接キー # OPENAI_API_KEY=your_openai_key # GEMINI_API_KEY=your_gemini_key # オプション:MongoDB永続化を有効化 # MONGODB_URI=your_mongodb_connection_stringフロントエンド設定:
uiディレクトリ内に.envファイルを作成します。最初にサンプルファイルをコピーできます:cp ui/.env.development.example ui/.env次に、
ui/.envを開いてフロントエンド環境変数を追加します:VITE_API_URL=http://localhost:8000 VITE_GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here- コンテナをビルド・起動:
docker compose up --buildこれによりバックエンドとフロントエンドサービスが起動します:
- バックエンドAPIは
http://localhost:8000で利用可能 - フロントエンドは
http://localhost:5174で利用可能
サービスを停止するには:
docker compose down注意:
.envの環境変数を更新する際は、コンテナを再起動する必要があります:docker compose down && docker compose upアプリケーションの実行
- バックエンドサーバーを起動(オプションを選択):
# オプション1:直接Pythonモジュール python -m application.py # オプション2:UvicornでFastAPI uvicorn application:app --reload --port 8000- 新しいターミナルでフロントエンドを起動:
cd ui npm run devhttp://localhost:5173でアプリケーションにアクセス
使用方法
モデルの選択
アプリ右上のモデルボタンをクリックすると設定パネルが開きます。ここではOpenRouterキーを貼り付けたり(キーはブラウザにのみ保存され、各調査リクエストと一緒に送信されます)、OpenRouterのモデルカタログをプロバイダー別にロゴ付きで検索・絞り込みできます。選択したモデルは調査パイプラインのすべてのステップで使用されます。OpenRouterキーがない場合、バックエンドは
OPENAI_API_KEYにフォールバックします(GEMINI_API_KEYが設定されていれば要約生成にGeminiを使用)。ローカル開発
-
バックエンドサーバーを起動(オプションを選択):
オプション1:直接Pythonモジュール
python -m application.pyオプション2:UvicornでFastAPI
# uvicornがインストールされていない場合はインストール # uv使用(より高速): uv pip install uvicorn # またはpip使用: # pip install uvicorn # ホットリロード付きでFastAPIアプリケーションを実行 uvicorn application:app --reload --port 8000バックエンドは以下で利用可能:
- APIエンドポイント:
http://localhost:8000
- APIエンドポイント:
-
フロントエンド開発サーバーを起動:
cd ui npm run dev -
http://localhost:5173でアプリケーションにアクセス
⚡ パフォーマンス注意: セットアップ時に
uvを使用した場合、大幅に高速なパッケージインストールと依存関係解決の恩恵を受けられます。uvはRustで書かれた現代的なPythonパッケージマネージャーで、pipより10〜100倍高速です。デプロイメントオプション
アプリケーションは様々なクラウドプラットフォームにデプロイできます。一般的なオプションをいくつか紹介します:
- Docker:アプリケーションにはコンテナ化デプロイメント用のDockerfileが含まれています
- Heroku:PythonビルドパックでGitHubから直接デプロイ
- Google Cloud Run:自動スケーリング付きコンテナ化デプロイメントに適しています
ニーズに最も適したプラットフォームを選択してください。アプリケーションはプラットフォーム非依存で、Pythonウェブアプリケーションがサポートされているどこでもホストできます。
貢献
- リポジトリをフォーク
- 機能ブランチを作成(
git checkout -b feature/amazing-feature) - 変更をコミット(
git commit -m 'Add some amazing feature') - ブランチにプッシュ(
git push origin feature/amazing-feature) - プルリクエストを開く
ライセンス
このプロジェクトはApache License 2.0の下でライセンスされています - 詳細はLICENSEファイルを参照してください。
謝辞
- 検索APIを提供するTavily
- その他すべてのオープンソースライブラリとその貢献者
Star History
company-research-agent/README.kr.mdmarkdown · 14.7 KB
기업 조사 에이전트 🔍
포괄적인 기업 조사 보고서를 생성하는 멀티 에이전트 도구입니다. 이 플랫폼은 AI 에이전트 파이프라인을 사용하여 모든 기업에 대한 정보를 수집, 정리 및 종합합니다.
✨온라인에서 체험해보세요! https://companyresearcher.tavily.com ✨
https://github.com/user-attachments/assets/0e373146-26a7-4391-b973-224ded3182a9
기능
- 멀티소스 조사: 기업 웹사이트, 뉴스 기사, 재무 보고서, 업계 분석 등 다양한 소스에서 데이터 수집
- AI 콘텐츠 필터링: Tavily의 관련성 점수를 사용한 콘텐츠 큐레이션
- 비동기 처리: 조사 진행 상황을 추적하기 위한 효율적인 폴링 기반 아키텍처
- 유연한 모델 선택: 설정에서 고른 OpenRouter 모델 하나로 모든 단계를 실행하며, 대안으로 OpenAI/Gemini API 키를 직접 사용할 수도 있습니다
- 모던 React 프론트엔드: 진행 상황 추적과 다운로드 옵션을 갖춘 반응형 인터페이스
- 모듈러 아키텍처: 전문화된 조사 및 처리 노드 파이프라인을 중심으로 구축
에이전트 프레임워크
조사 파이프라인
이 플랫폼은 데이터를 순차적으로 처리하는 전문화된 노드를 가진 에이전트 프레임워크를 따릅니다:
-
조사 노드:
CompanyAnalyzer: 핵심 기업 정보 조사IndustryAnalyzer: 시장 위치 및 트렌드 분석FinancialAnalyst: 재무 지표 및 성과 데이터 수집NewsScanner: 최신 뉴스 및 개발 사항 수집
-
처리 노드:
Collector: 모든 분석기에서 조사 데이터 집계Curator: 콘텐츠 필터링 및 관련성 점수 매기기 구현Briefing: 선택한 모델을 사용하여 카테고리별 요약 생성Editor: 선택한 모델로 요약을 최종 보고서로 컴파일 및 형식 지정
콘텐츠 생성 아키텍처
모든 LLM 단계(조사 노드의 쿼리 생성,
Briefing,Editor)는 하나의 모델을 사용합니다. 어떤 공급자를 사용할지는backend/llm.py에서 결정됩니다:-
OpenRouter (권장):
- OpenRouter 키가 있으면(백엔드
.env의OPENROUTER_API_KEY또는 앱 설정 패널에 붙여넣은 키) 모든 단계에서 설정에서 선택한 OpenRouter 모델을 사용합니다 - 기본 모델은
google/gemini-3.8-flash이며,OPENROUTER_MODEL로 변경할 수 있습니다
- OpenRouter 키가 있으면(백엔드
-
직접 공급자 키 (대안):
- OpenRouter 키가 없으면
OPENAI_API_KEY(GPT 모델)를 사용합니다 GEMINI_API_KEY가 설정된 경우에만 브리핑에 Gemini 2.5 Flash를 사용합니다 (Gemini 키는 더 이상 필수가 아닙니다)
- OpenRouter 키가 없으면
콘텐츠 큐레이션 시스템
이 플랫폼은
curator.py에서 콘텐츠 필터링 시스템을 사용합니다:-
관련성 점수 매기기:
- 문서는 Tavily의 AI 검색으로 점수가 매겨집니다
- 계속 진행하려면 최소 임계값(기본값 0.4)이 필요합니다
- 점수는 검색 쿼리와의 관련성을 반영합니다
- 높은 점수는 검색 의도와의 더 나은 일치를 나타냅니다
-
문서 처리:
- 콘텐츠가 정규화되고 정리됩니다
- URL이 중복 제거되고 표준화됩니다
- 문서가 관련성 점수로 정렬됩니다
- 조사가 백그라운드에서 비동기적으로 실행됩니다
백엔드 아키텍처
플랫폼은 폴링 기반의 간단한 통신 시스템을 구현합니다:
-
백엔드 구현:
- 비동기 지원이 있는 FastAPI 사용
- 조사 작업이 백그라운드에서 실행
- 결과는 REST 엔드포인트를 통해 저장 및 액세스
- 간단한 작업 상태 추적
-
프론트엔드 통합:
- React 프론트엔드가 조사 요청을 제출
- 추적을 위한 job_id 수신
/research/{job_id}/report엔드포인트 폴링- 완료 시 최종 보고서 표시
-
API 엔드포인트:
POST /research: 새 조사 요청 제출 (선택적으로openrouter_api_key및model지정 가능)GET /research/{job_id}/report: 완료된 보고서 폴링POST /generate-pdf: 보고서 콘텐츠에서 PDF 생성GET /models: OpenRouter 텍스트 모델 목록 (1시간 캐시)GET /config: 서버에 설정된 LLM 키 정보 및 기본 모델
설정
빠른 설정 (권장)
시작하는 가장 쉬운 방법은 설정 스크립트를 사용하는 것입니다. 이 스크립트는 자동으로
uv를 감지하고 사용 가능할 때 더 빠른 Python 패키지 설치에 사용합니다:- 저장소 클론:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- 설정 스크립트를 실행 가능하게 만들고 실행:
chmod +x setup.sh ./setup.sh설정 스크립트는 다음을 수행합니다:
uv를 감지하고 더 빠른 Python 패키지 설치에 사용 (사용 가능한 경우)- 필요한 Python 및 Node.js 버전 확인
- Python 가상 환경 생성 (권장)
- 모든 종속성 설치 (Python 및 Node.js)
- 환경 변수 설정 안내 (Tavily 키와 OpenRouter 키 입력 요청, OpenRouter 키를 건너뛴 경우에만 OpenAI/Gemini 키 요청)
- 백엔드 및 프론트엔드 서버 시작 (선택사항)
💡 프로 팁: uv를 설치하여 훨씬 빠른 Python 패키지 설치를 이용하세요:
curl -LsSf https://astral.sh/uv/install.sh | sh다음 API 키가 필요합니다:
- Tavily API 키
- OpenRouter API 키 (권장 - 나중에 앱 설정에서 붙여넣을 수도 있음)
- OpenAI API 키 (OpenRouter를 사용하지 않는 경우에만)
- Google Gemini API 키 (선택사항)
- Google Maps API 키 (선택사항 - 위치 자동완성에만 필요)
- MongoDB URI (선택사항)
수동 설정
수동으로 설정하려면 다음 단계를 따르세요:
- 저장소 클론:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- 백엔드 종속성 설치:
# 선택사항: 가상 환경 생성 및 활성화 # uv 사용 (더 빠름 - 사용 가능한 경우 권장): uv venv .venv source .venv/bin/activate # 또는 표준 Python: # python -m venv .venv # source .venv/bin/activate # Python 종속성 설치 # uv 사용 (더 빠름): uv pip install -r requirements.txt # 또는 pip 사용: # pip install -r requirements.txt- 프론트엔드 종속성 설치:
cd ui npm install- 환경 변수 설정:
이 프로젝트는 백엔드와 프론트엔드용으로 두 개의 별도
.env파일이 필요합니다.백엔드 설정:
프로젝트 루트 디렉토리에
.env파일을 생성하고 백엔드 API 키를 추가합니다:TAVILY_API_KEY=your_tavily_key # 권장: 모든 모델에 사용할 수 있는 단일 키 (또는 앱 설정에 키를 붙여넣기) OPENROUTER_API_KEY=your_openrouter_key # 선택사항: 기본 모델 (사용자는 설정에서 다른 모델을 선택할 수 있음) # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # OpenRouter 대안: 공급자 키 직접 사용 # OPENAI_API_KEY=your_openai_key # GEMINI_API_KEY=your_gemini_key # 선택사항: MongoDB 지속성 활성화 # MONGODB_URI=your_mongodb_connection_string프론트엔드 설정:
ui디렉토리 내에.env파일을 생성합니다. 먼저 예제 파일을 복사할 수 있습니다:cp ui/.env.development.example ui/.env그런 다음,
ui/.env를 열고 프론트엔드 환경 변수를 추가합니다:VITE_API_URL=http://localhost:8000 VITE_GOOGLE_MAPS_API_KEY=your_google_maps_api_key_hereDocker 설정
애플리케이션은 Docker 및 Docker Compose를 사용하여 실행할 수 있습니다:
- 저장소 클론:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- 환경 변수 설정:
Docker 설정은 두 개의 별도
.env파일을 사용합니다.백엔드 설정:
프로젝트 루트 디렉토리에
.env파일을 생성하고 백엔드 API 키를 추가합니다:TAVILY_API_KEY=your_tavily_key # 권장: 모든 모델에 사용할 수 있는 단일 키 (또는 앱 설정에 키를 붙여넣기) OPENROUTER_API_KEY=your_openrouter_key # 선택사항: 기본 모델 (사용자는 설정에서 다른 모델을 선택할 수 있음) # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # OpenRouter 대안: 공급자 키 직접 사용 # OPENAI_API_KEY=your_openai_key # GEMINI_API_KEY=your_gemini_key # 선택사항: MongoDB 지속성 활성화 # MONGODB_URI=your_mongodb_connection_string프론트엔드 설정:
ui디렉토리 내에.env파일을 생성합니다. 먼저 예제 파일을 복사할 수 있습니다:cp ui/.env.development.example ui/.env그런 다음,
ui/.env를 열고 프론트엔드 환경 변수를 추가합니다:VITE_API_URL=http://localhost:8000 VITE_GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here- 컨테이너 빌드 및 시작:
docker compose up --build이렇게 하면 백엔드 및 프론트엔드 서비스가 시작됩니다:
- 백엔드 API는
http://localhost:8000에서 사용 가능 - 프론트엔드는
http://localhost:5174에서 사용 가능
서비스를 중지하려면:
docker compose down참고:
.env의 환경 변수를 업데이트할 때 컨테이너를 다시 시작해야 합니다:docker compose down && docker compose up애플리케이션 실행
- 백엔드 서버 시작 (옵션 선택):
# 옵션 1: 직접 Python 모듈 python -m application.py # 옵션 2: Uvicorn으로 FastAPI uvicorn application:app --reload --port 8000- 새 터미널에서 프론트엔드 시작:
cd ui npm run devhttp://localhost:5173에서 애플리케이션에 액세스
⚡ 성능 참고사항: 설정 중에
uv를 사용했다면 훨씬 빠른 패키지 설치와 종속성 해결의 이점을 얻을 수 있습니다.uv는 Rust로 작성된 현대적인 Python 패키지 관리자로 pip보다 10-100배 빠를 수 있습니다.사용법
모델 선택
앱 오른쪽 상단의 모델 버튼을 클릭하면 설정 패널이 열립니다. 여기서 OpenRouter 키를 붙여넣을 수 있으며(브라우저에만 저장되고 각 조사 요청과 함께 전송됨), 전체 OpenRouter 모델 카탈로그를 공급자별로 검색하거나 필터링할 수 있습니다. 선택한 모델은 조사 파이프라인의 모든 단계에 사용됩니다. OpenRouter 키가 없으면 백엔드는
OPENAI_API_KEY로 대체하며,GEMINI_API_KEY가 설정된 경우 브리핑에는 Gemini를 사용합니다.로컬 개발
-
백엔드 서버 시작 (옵션 선택):
옵션 1: 직접 Python 모듈
python -m application.py옵션 2: Uvicorn으로 FastAPI
# uvicorn이 설치되지 않은 경우 설치 # uv 사용 (더 빠름): uv pip install uvicorn # 또는 pip 사용: # pip install uvicorn # 핫 리로드로 FastAPI 애플리케이션 실행 uvicorn application:app --reload --port 8000백엔드는 다음에서 사용 가능합니다:
- API 엔드포인트:
http://localhost:8000
- API 엔드포인트:
-
프론트엔드 개발 서버 시작:
cd ui npm run dev -
http://localhost:5173에서 애플리케이션에 액세스
배포 옵션
애플리케이션은 다양한 클라우드 플랫폼에 배포할 수 있습니다. 몇 가지 일반적인 옵션은 다음과 같습니다:
- Docker: 애플리케이션에는 컨테이너화된 배포를 위한 Dockerfile이 포함되어 있습니다
- Heroku: Python 빌드팩으로 GitHub에서 직접 배포
- Google Cloud Run: 자동 스케일링을 통한 컨테이너화된 배포에 적합
귀하의 요구 사항에 가장 적합한 플랫폼을 선택하세요. 애플리케이션은 플랫폼에 구애받지 않으며 Python 웹 애플리케이션이 지원되는 곳이면 어디든 호스팅할 수 있습니다.
기여
- 저장소 포크
- 기능 브랜치 생성 (
git checkout -b feature/amazing-feature) - 변경 사항 커밋 (
git commit -m 'Add some amazing feature') - 브랜치에 푸시 (
git push origin feature/amazing-feature) - Pull Request 열기
라이선스
이 프로젝트는 Apache License 2.0 하에 라이선스가 부여됩니다 - 자세한 내용은 LICENSE 파일을 참조하세요.
감사의 말
- 검색 API를 제공하는 Tavily
- 모든 다른 오픈 소스 라이브러리와 그 기여자들
Star History
company-research-agent/README.mdmarkdown · 8.8 KB
Company Research Agent
An agentic research tool that turns a company name into a structured, source-backed briefing. The FastAPI backend coordinates specialized research and synthesis steps; the React interface tracks the job and lets you export the finished report as a PDF.
Company Research Agent landing page
What it does
- Researches a company’s business, industry, financial context, and recent news.
- Uses Tavily search and relevance scoring to find and curate source material.
- Runs on any model available through OpenRouter — pick it from the in-app Settings — or on direct OpenAI/Gemini keys.
- Streams research progress to the browser and supports PDF export.
- Optionally persists jobs and reports in MongoDB.
Architecture
React + Vite UI │ POST /research ▼ FastAPI application │ ▼ Research graph analyzers → collector → curator → briefing → editor │ ├── Tavily: research and relevance scoring └── LLM: query generation, briefings, final editing (OpenRouter model chosen in Settings, or OpenAI/Gemini directly)The UI connects to the API with
VITE_API_URLand receives incremental updates fromGET /research/{job_id}/stream.Prerequisites
- Python 3.11 or later
- Node.js 18 or later
- An OpenRouter API key — set in the backend
.envor pasted into the app's Settings (alternatively, an OpenAI key, plus an optional Gemini key) - A Tavily API key entered in the app for each research session (or a backend fallback key)
- A Google Maps API key only if you want location autocomplete
- MongoDB only if you want persistent jobs and reports
Run locally
-
Create a virtual environment and install the backend dependencies:
uv venv .venv uv pip install -r requirements.txtIf you do not use
uv, create the environment withpython -m venv .venvand install withpip install -r requirements.txt. -
Create
.envfrom the example and set the required backend keys:cp .env.example .env# Recommended: one key for every model (or paste a key in the app's Settings instead) OPENROUTER_API_KEY=your_openrouter_key # Optional default model; users can still pick another in Settings # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # Alternative to OpenRouter: direct provider keys # OPENAI_API_KEY=your_openai_key # GEMINI_API_KEY=your_gemini_key # Optional fallback when no per-session Tavily key is supplied TAVILY_API_KEY=your_tavily_key # MONGODB_URI=optional_mongodb_connection_string -
Configure the frontend:
cp ui/.env.development.example ui/.env.development.local cd ui && npm install && cd ..Set
VITE_API_URL=http://localhost:8000inui/.env.development.local. AddVITE_GOOGLE_MAPS_API_KEYonly when location autocomplete is needed. -
Start the API in one terminal:
.venv/bin/uvicorn application:app --reload --port 8000 -
Start the UI in a second terminal:
cd ui npm run dev
Open http://localhost:5174. The API is available at http://localhost:8000/docs.
Alternatively,
./setup.shinstalls everything, asks for your Tavily and OpenRouter keys, and writes.envfor you.Choosing a model
Click the model button in the top-right corner to open Settings. There you can paste an OpenRouter key (stored only in your browser and sent with each research request) and search or filter the full OpenRouter catalog by provider. The selected model is used for every step of the research pipeline. If no OpenRouter key is available, the backend falls back to
OPENAI_API_KEY(with Gemini for briefings whenGEMINI_API_KEYis set).Docker
After creating the root
.envandui/.env.development.localfiles, run:docker compose up --buildThis exposes the API on port
8000and the UI on port5174.API
Method Endpoint Purpose POST/researchStart a research job. Accepts optional openrouter_api_keyandmodel.GET/modelsList text models available on OpenRouter (cached for an hour). GET/configReport which LLM keys the server has configured and the default model. GET/research/{job_id}/streamReceive server-sent progress and completion events. GET/research/{job_id}/reportRetrieve a completed report. POST/generate-pdfGenerate a PDF from report Markdown. GET/research/pdf/{filename}Download a generated PDF. Example request:
curl -X POST http://localhost:8000/research \ -H 'Content-Type: application/json' \ -d '{ "company": "Tavily", "company_url": "https://tavily.com", "industry": "AI search", "hq_location": "New York, USA", "model": "anthropic/claude-sonnet-5.5" }'modelandopenrouter_api_keyare optional. Whenopenrouter_api_keyis omitted, the backend usesOPENROUTER_API_KEYfrom its environment; when neither is set, it falls back to OpenAI/Gemini and ignoresmodel.Configuration reference
Variable Required Used by TAVILY_API_KEYNo Backend fallback for research and curation; visitors can supply their own key in the UI OPENROUTER_API_KEYNo* All LLM steps via OpenRouter; users can supply their own key in Settings instead OPENROUTER_MODELNo Default OpenRouter model ( google/gemini-3.8-flashif unset)OPENAI_API_KEYNo* Research and editing when no OpenRouter key is available GEMINI_API_KEYNo Briefing synthesis when no OpenRouter key is available (falls back to OpenAI) MONGODB_URINo Backend job/report persistence VITE_API_URLYes Frontend API connection VITE_GOOGLE_MAPS_API_KEYNo Frontend location autocomplete * An LLM key is needed from somewhere:
OPENROUTER_API_KEY,OPENAI_API_KEY, or an OpenRouter key entered in the app's Settings.License
TalentDelta provenance record (added at packaging)
- Packaged for: TalentDelta catalogue (draft; gated by review)
- Upstream: guy-hartstein/company-research-agent
- Pinned commit:
abbbc3a30f35d78afa9a1790053a18c0e60fe552(2026-10-22) - Licence: Apache-2.0 — Apache License 2.0 at the repository root, shipped byte-identical to the pin. Permissive: use, modification, distribution and commercial use allowed with attribution and licence text. No NOTICE file exists upstream; no per-file headers are added and no shipped file is edited. Attribution to Guy Hartstein is recorded in the pack README's provenance section and in this upload record.
- Upstream author credit: Guy Hartstein (guy-hartstein)
- Runtime: Python 3.11+ (
uv pip install -r requirements.txt), Node.js 18+ for the React UI, LangGraph via langgraph.json; a Tavily API key per session, an OpenRouter key (or OpenAI and optional Gemini keys), a Google Maps key only for location autocomplete, MongoDB only for persistence; Docker and docker-compose supported - Changes made at packaging: this section was appended to
README.md. Upstream paths deliberately NOT shipped in this archive:static/demo.mp4(Product demo video (15,056,071 bytes), regenerable from upstream and not needed to run the agent; screenshots in static/ are kept.). Apart from this provenance section appended toREADME.md, no file was added, removed, renamed or edited; every upstream file is byte-identical to the pinned commit. - Safety read of the packaged archive (packaging time, 2026-10-24): Static safety read of the shipped archive (143 files): 2 pattern hits, both api-key patterns - a front-end environment read in LocationInput.tsx and an environment read in backend/llm.py. No address, absolute path, credential, token or private key; .env.example and the UI development example hold placeholders only.
Packaging notes (TalentDelta)
- The pipeline is a real LangGraph state machine: backend/graph.py wires researchers → collector → curator → enricher → grounding → briefing → editor, each node a module with its own prompt block in backend/prompts.py.
- Source discipline is explicit: the Tavily search results are relevance-scored and curated before briefing, and a grounding node checks claims against collected references (backend/utils/references.py).
- No keys ship. .env.example and ui/.env.development.example hold placeholders only; Tavily, OpenRouter/OpenAI/Gemini, Google Maps and MongoDB credentials are supplied by the operator.
- Reports are about companies, not people: no personal profiles are bundled and nothing scrapes private data; all retrieval is ordinary web search through Tavily.
- MongoDB is optional; without it jobs and reports live only for the process lifetime.
company-research-agent/README.zh.mdmarkdown · 12.5 KB
智能公司研究助手 🔍
一个多智能体工具,用于生成全面的公司研究报告。该平台使用一系列AI智能体来收集、整理和综合任何公司的信息。
✨快来看看吧!https://companyresearcher.tavily.com ✨
https://github.com/user-attachments/assets/071aa491-009b-4d76-a698-88863149e71c
功能特点
- 多源研究:从公司网站、新闻文章、财务报告和行业分析等多个来源收集数据
- AI驱动的内容过滤:使用Tavily的相关性评分进行内容筛选
- 异步处理:基于轮询的高效架构,用于跟踪研究进度
- 灵活的模型选择:可在设置中选择任意OpenRouter模型,或使用OpenAI/Gemini直连密钥作为备选
- 现代React前端:具有进度跟踪和下载选项的响应式UI
- 模块化架构:使用专业研究和处理节点构建的管道
智能体框架
研究管道
该平台遵循智能体框架,使用专门的节点按顺序处理数据:
-
研究节点:
CompanyAnalyzer:研究核心业务信息IndustryAnalyzer:分析市场定位和趋势FinancialAnalyst:收集财务指标和业绩数据NewsScanner:收集最新新闻和发展动态
-
处理节点:
Collector:汇总所有分析器的研究数据Curator:实现内容过滤和相关性评分Briefing:使用所选模型生成特定类别的摘要Editor:使用所选模型将简报编译和格式化为最终报告
内容生成架构
所有LLM步骤(研究节点中的查询生成、
Briefing和Editor)都使用同一个模型,模型提供方在backend/llm.py中选择:-
OpenRouter(推荐):
- 当后端
.env中设置了OPENROUTER_API_KEY,或用户在应用设置中粘贴了OpenRouter密钥时启用 - 每个步骤都使用在设置中选择的OpenRouter模型
- 默认模型为
google/gemini-3.8-flash,可通过OPENROUTER_MODEL覆盖
- 当后端
-
直连密钥(备选):
- 没有OpenRouter密钥时,后端回退到
OPENAI_API_KEY(GPT模型) - 仅当设置了
GEMINI_API_KEY时,才使用Gemini 2.5 Flash生成简报 - Gemini密钥不再是必需的
- 没有OpenRouter密钥时,后端回退到
内容筛选系统
该平台在
curator.py中使用内容过滤系统:-
相关性评分:
- 文档由Tavily的AI驱动搜索进行评分
- 需要达到最低阈值(默认0.4)才能继续
- 分数反映与特定研究查询的相关性
- 更高的分数表示与研究意图更好的匹配
-
文档处理:
- 内容被标准化和清理
- URL被去重和标准化
- 文档按相关性分数排序
- 研究在后台异步运行
后端架构
该平台实现了基于轮询的简单通信系统:
-
后端实现:
- 使用带有异步支持的FastAPI
- 研究任务在后台运行
- 结果通过REST端点存储和访问
- 简单的作业状态跟踪
-
前端集成:
- React前端提交研究请求
- 接收用于跟踪的job_id
- 轮询
/research/{job_id}/report端点 - 完成时显示最终报告
-
API端点:
POST /research:提交新的研究请求(可选参数openrouter_api_key和model)GET /models:列出OpenRouter上可用的文本模型(缓存1小时)GET /config:返回服务器已配置的LLM密钥及默认模型GET /research/{job_id}/report:轮询已完成的报告POST /generate-pdf:从报告内容生成PDF
安装设置
快速安装(推荐)
最简单的方法是使用安装脚本,它会自动检测并使用
uv来实现更快的 Python 包安装(如果可用):- 克隆仓库:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- 使安装脚本可执行并运行:
chmod +x setup.sh ./setup.sh安装脚本将:
- 检测并使用
uv进行更快的 Python 包安装(如果可用) - 检查所需的Python和Node.js版本
- 可选创建Python虚拟环境(推荐)
- 安装所有依赖(Python和Node.js)
- 指导您设置环境变量(会询问您的Tavily密钥和OpenRouter密钥;仅在跳过OpenRouter密钥时才询问OpenAI/Gemini密钥)
- 可选启动后端和前端服务器
💡 专业提示: 安装 uv 以获得显著更快的 Python 包安装:
curl -LsSf https://astral.sh/uv/install.sh | sh您需要准备以下API密钥:
- Tavily API密钥
- OpenRouter API密钥(推荐,也可以稍后在应用设置中粘贴)
- OpenAI API密钥(仅在不使用OpenRouter时需要)
- Google Gemini API密钥(可选)
- Google Maps API密钥(可选,仅用于地点自动补全)
- MongoDB URI(可选)
手动安装
如果您更喜欢手动安装,请按照以下步骤操作:
- 克隆仓库:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- 安装后端依赖:
# 可选:创建并激活虚拟环境 # 使用 uv(更快 - 如果可用则推荐): uv venv .venv source .venv/bin/activate # 或使用标准 Python: # python -m venv .venv # source .venv/bin/activate # 安装 Python 依赖 # 使用 uv(更快): uv pip install -r requirements.txt # 或使用 pip: # pip install -r requirements.txt- 安装前端依赖:
cd ui npm install- 设置环境变量:
此项目需要两个单独的
.env文件用于后端和前端。后端配置:
在项目根目录创建
.env文件并添加您的后端API密钥:TAVILY_API_KEY=your_tavily_key # 推荐:一个密钥即可使用所有模型(也可以改为在应用设置中粘贴密钥) OPENROUTER_API_KEY=your_openrouter_key # 可选的默认模型;用户仍可在设置中选择其他模型 # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # OpenRouter的替代方案:直接使用模型提供方的密钥 # OPENAI_API_KEY=your_openai_key # GEMINI_API_KEY=your_gemini_key # 可选:启用MongoDB持久化 # MONGODB_URI=your_mongodb_connection_string前端配置:
在
ui目录内创建.env文件。您可以先复制示例文件:cp ui/.env.development.example ui/.env然后,打开
ui/.env并添加您的前端环境变量:VITE_API_URL=http://localhost:8000 VITE_GOOGLE_MAPS_API_KEY=your_google_maps_api_key_hereDocker安装
可以使用Docker和Docker Compose运行应用程序:
- 克隆仓库:
git clone https://github.com/guy-hartstein/company-research-agent.git cd company-research-agent- 设置环境变量:
Docker 设置使用两个单独的
.env文件。后端配置:
在项目根目录创建
.env文件并添加您的后端API密钥:TAVILY_API_KEY=your_tavily_key # 推荐:一个密钥即可使用所有模型(也可以改为在应用设置中粘贴密钥) OPENROUTER_API_KEY=your_openrouter_key # 可选的默认模型;用户仍可在设置中选择其他模型 # OPENROUTER_MODEL=anthropic/claude-sonnet-5.5 # OpenRouter的替代方案:直接使用模型提供方的密钥 # OPENAI_API_KEY=your_openai_key # GEMINI_API_KEY=your_gemini_key # 可选:启用MongoDB持久化 # MONGODB_URI=your_mongodb_connection_string前端配置:
在
ui目录内创建.env文件。您可以先复制示例文件:cp ui/.env.development.example ui/.env然后,打开
ui/.env并添加您的前端环境变量:VITE_API_URL=http://localhost:8000 VITE_GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here- 构建并启动容器:
docker compose up --build这将启动后端和前端服务:
- 后端API将在
http://localhost:8000可用 - 前端将在
http://localhost:5174可用
停止服务:
docker compose down注意:更新
.env中的环境变量时,需要重启容器:docker compose down && docker compose up运行应用程序
- 启动后端服务器(选择一种方式):
# 选项1:直接Python模块 python -m application.py # 选项2:使用Uvicorn的FastAPI uvicorn application:app --reload --port 8000- 在新终端中启动前端:
cd ui npm run dev- 在
http://localhost:5173访问应用程序
⚡ 性能说明: 如果您在设置过程中使用了
uv,您将受益于显著更快的包安装和依赖解析。uv是一个用 Rust 编写的现代 Python 包管理器,比 pip 快 10-100 倍。使用方法
选择模型
点击应用右上角的模型按钮即可打开设置面板。您可以在此粘贴OpenRouter密钥(仅保存在您的浏览器中,并随每次研究请求一起发送),并按提供方搜索或筛选完整的OpenRouter模型目录。所选模型将用于研究管道的每个步骤;如果没有可用的OpenRouter密钥,后端会回退到
OPENAI_API_KEY(设置了GEMINI_API_KEY时使用Gemini生成简报)。本地开发
-
启动后端服务器(选择一个选项):
选项1:直接Python模块
python -m application.py选项2:使用Uvicorn的FastAPI
# 如果尚未安装,安装uvicorn # 使用 uv(更快): uv pip install uvicorn # 或使用 pip: # pip install uvicorn # 使用热重载运行FastAPI应用 uvicorn application:app --reload --port 8000后端将在以下位置可用:
- API端点:
http://localhost:8000
- API端点:
-
启动前端开发服务器:
cd ui npm run dev -
在
http://localhost:5173访问应用程序
部署选项
该应用程序可以部署到各种云平台。以下是一些常见选项:
- Docker:应用程序包含用于容器化部署的Dockerfile
- Heroku:使用Python构建包直接从GitHub部署
- Google Cloud Run:适用于具有自动扩展功能的容器化部署
选择最适合您需求的平台。该应用程序是平台无关的,可以托管在任何支持Python Web应用程序的地方。
贡献
- Fork仓库
- 创建特性分支(
git checkout -b feature/amazing-feature) - 提交更改(
git commit -m 'Add amazing feature') - 推送到分支(
git push origin feature/amazing-feature) - 打开Pull Request
许可证
本项目采用Apache License 2.0许可证 - 详情请参阅LICENSE文件。
致谢
- Tavily提供研究API
- 所有其他开源库及其贡献者
Star History
company-research-agent/application.pycode · 13.1 KB · preview truncated
import asyncio import json import logging import os import time import uuid from datetime import datetime from pathlib import Path import httpx import uvicorn from dotenv import load_dotenv from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pydantic import BaseModel from backend.graph import Graph from backend.llm import DEFAULT_OPENROUTER_MODEL, OPENROUTER_BASE_URL from backend.services.mongodb import MongoDBService from backend.services.pdf_service import PDFService from backend.classes.state import EventQueue, job_status # Load environment variables from .env file at startup env_path = Path(__file__).parent / '.env' if env_path.exists(): load_dotenv(dotenv_path=env_path, override=True) # Configure logging logger = logging.getLogger() logger.setLevel(logging.INFO) console_handler = logging.StreamHandler() logger.addHandler(console_handler) app = FastAPI(title="Tavily Company Research API") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], ) pdf_service = PDFService({"pdf_output_dir": "pdfs"}) mongodb = None if mongo_uri := os.getenv("MONGODB_URI"): try: mongodb = MongoDBService(mongo_uri) logger.info("MongoDB integration enabled") except Exception as e: logger.warning(f"Failed to initialize MongoDB: {e}. Continuing without persistence.") class ResearchRequest(BaseModel): company: str company_url: str | None = None industry: str | None = None hq_location: str | None = None tavily_api_key: str | None = None openrouter_api_key: str | None = None model: str | None = None class PDFGenerationRequest(BaseModel): report_content: str company_name: str | None = None @app.options("/research") async def preflight(): response = JSONResponse(content=None, status_code=200) response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" return response @app.post("/research") async def research(data: ResearchRequest): try: logger.info(f"Received research request for {data.company}") job_id = str(uuid.uuid4()) # Create the job before starting the background task. The SSE endpoint # begins polling immediately after this response; without this entry it # can time out while initial research work is still running. job_status[job_id].update({ "status": "pending", "company": data.company, "last_update": datetime.now().isoformat(), }) asyncio.create_task(process_research(job_id, data)) response = JSONResponse(content={ "status": "accepted", "job_id": job_id, "message": "Research started. Connect to /research/{job_id}/stream for updates." }) response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" return response except Exception as e: logger.error(f"Error initiating research: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) async def process_research(job_id: str, data: ResearchRequest): """Process research request asynchronously and store results""" try: if mongodb: mongodb.create_job(job_id, data.model_dump(exclude={"tavily_api_key", "openrouter_api_key"})) await asyncio.sleep(0.5) # Brief delay logger.info(f"Starting research for {data.company}") graph = Graph( company=data.company, url=data.company_url, industry=data.industry, hq_location=data.hq_location, job_id=job_id, tavily_api_key=data.tavily_api_key, openrouter_api_key=data.openrouter_api_key, model=data.model, ) final_state = {} # Stream through the graph and update progress async for state in graph.run(thread={}): final_state.update(state) node_name = list(state.keys())[0] if state else 'unknown' logger.debug(f"Node completed: {node_name}") # Update job status with current step job_status[job_id].update({ "status": "processing", "current_step": node_name, "last_update": datetime.now().isoformat() }) # Extract final report report_content = final_state.get('report') or (final_state.get('editor') or {}).get('report') if report_content: logger.info(f"Research completed. Report length: {len(report_content)}") job_status[job_id].update({ "status": "completed", "report": report_content, "company": data.company, "last_update": datetime.now().isoformat() }) if mongodb: mongodb.update_job(job_id=job_id, status="completed") mongodb.store_report(job_id=job_id, report_data={"report": report_content}) logger.info(f"Research completed successfully for {data.company}") else: logger.error(f"Research completed without report. State keys: {list(final_state.keys())}") job_status[job_id].update({ "status": "failed", "error": "No report generated", "last_update": datetime.now().isoformat() }) except Exception as e: logger.error(f"Research failed: {str(e)}", exc_info=True) job_status[job_id].update({ "status": "failed", "error": str(e), "last_update": datetime.now().isoformat() }) if mongodb: mongodb.update_job(job_id=job_id, status="failed", error=str(e)) @app.get("/") async def ping(): return {"message": "Alive"} @app.get("/config") async def get_config(): """Report which LLM credentials the server already has, without exposing them.""" return { "openrouter_configured": bool(os.getenv("OPENROUTER_API_KEY")), "openai_configured": bool(os.getenv("OPENAI_API_KEY")), "default_model": os.getenv("OPENROUTER_MODEL") or DEFAULT_OPENROUTER_MODEL, } _models_cache: dict = {"fetched_at": 0.0, "models": []} MODELS_CACHE_TTL = 3600 def _per_million(price: str | None) -> float | None: try: value = float(price) except (TypeError, ValueError): return None return round(value * 1_000_000, 4) if value >= 0 else None @app.get("/models") async def list_models(): """List text-generation models available on OpenRouter (cached).""" if _models_cache["models"] and time.time() - _models_cache["fetched_at"] < MODELS_CACHE_TTL: return {"models": _models_cache["models"]} try: async with httpx.AsyncClient(timeout=15) as client: response = await client.get(f"{OPENROUTER_BASE_URL}/models") response.raise_for_status() raw_models = response.json().get("data", []) except Exception as e: logger.warning(f"Failed to fetch OpenRouter models: {e}") if _models_cache["models"]: return {"models": _models_cache["models"]} raise HTTPException(status_code=502, detail="Could not load models from OpenRouter") models = [] for m in raw_models: model_id = m.get("id", "") output_modalities = (m.get("architecture") or {}).get("output_modalities") or ["text"] prompt_price = _per_million((m.get("pricing") or {}).get("prompt")) if model_id.endswith(":batch") or "text" not in output_modalities or prompt_price is None: continue name = m.get("name") or model_id models.append({ "id": model_id, "name": name.split(": ", 1)[-1], "provider": model_id.lstrip("~").split("/", 1)[0], "context_length": m.get("context_length"), "prompt_price": prompt_price, "completion_price": _per_million((m.get("pricing") or {}).get("completion")), "created": m.get("created", 0), }) models.sort(key=lambda model: model["created"], reverse=True) _models_cache.update(fetched_at=time.time(), models=models) return {"models": models} @app.get("/research/pdf/{filename}") async def get_pdf(filename: str): pdf_path = os.path.join("pdfs", filename) if not os.path.exists(pdf_path): raise HTTPException(status_code=404, detail="PDF not found") return FileResponse(pdf_path, media_type='application/pdf', filename=filename) @app.get("/research/{job_id}") async def get_research(job_id: str): if not mongodb: raise HTTPException(status_code=501, detail="Database persistence not configured") job = mongodb.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Research job not found") return job @app.get("/research/{job_id}/stream") async def stream_research(job_id: str): """Stream research progress via SSE""" async def event_generator(): try: # Wait for job to exist for _ in range(50): if job_id in job_status: break await asyncio.sleep(0.1) last_step = None # Stream status updates while job_id in job_status: result = job_status[job_id] status = result.get("status") current_step = result.get("current_step") events = result.get("events", []) # Send node progress updates when step changes if status == "processing" and current_step and current_step != last_step: data = json.dumps({"type": "progress", "step": current_step}) yield f"data: {data}\n\n" last_step = current_step # Send all queued events (FIFO - pop from start) while events: event = events.pop(0) data = json.dumps(event) yield f"data: {data}\n\n" if status == "completed" and (report := result.get("report")): data = json.dumps({"type": "complete", "report": report}) yield f"data: {data}\n\n" break elif status == "failed": data = json.dumps({"type": "error", "error": result.get("error", "Unknown error")}) yield f"data: {data}\n\n" break # Wake immediately on new events; the timeout catches status changes if isinstance(events, EventQueue): await events.wait(timeout=0.1) else: await asyncio.sleep(0.1) except Exception as e: data = json.dumps({"type": "error", "error": str(e)}) yield f"data: {data}\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream") @app.get("/research/{job_id}/report") async def get_research_report(job_id: str): if not mongodb: if job_id in job_status: result = job_status[job_id] if report := result.get("report"): return {"report": report} # Job exists but report not ready yet return JSONResponse( status_code=202,company-research-agent/backend/__init__.pycode · 0.9 KB
"""Backend package for tavily-company-research.""" import logging import os from pathlib import Path from dotenv import load_dotenv from .graph import Graph # Set up logging logger = logging.getLogger(__name__) # Load environment variables from .env file env_path = Path(__file__).parent.parent / '.env' if env_path.exists(): logger.info(f"Loading environment variables from {env_path}") load_dotenv(dotenv_path=env_path, override=True) else: logger.warning(f".env file not found at {env_path}. Using system environment variables.") # Check for critical environment variables if not os.getenv("TAVILY_API_KEY"): logger.warning("TAVILY_API_KEY environment variable is not set.") if not (os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")): logger.warning( "Neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set. " "Research will require an OpenRouter key supplied from the UI settings." ) __all__ = ["Graph"]company-research-agent/backend/classes/__init__.pycode · 0.1 KB
from .state import InputState, ResearchState __all__ = ["InputState", "ResearchState"]company-research-agent/backend/classes/state.pycode · 1.9 KB
import asyncio from typing import TypedDict, NotRequired, Required, Dict, List, Any from collections import defaultdict from datetime import datetime #Define the input state class InputState(TypedDict, total=False): company: Required[str] company_url: NotRequired[str] hq_location: NotRequired[str] industry: NotRequired[str] job_id: NotRequired[str] class ResearchState(InputState): site_scrape: Dict[str, Any] messages: List[Any] financial_data: Dict[str, Any] news_data: Dict[str, Any] industry_data: Dict[str, Any] company_data: Dict[str, Any] curated_financial_data: Dict[str, Any] curated_news_data: Dict[str, Any] curated_industry_data: Dict[str, Any] curated_company_data: Dict[str, Any] financial_briefing: str news_briefing: str industry_briefing: str company_briefing: str references: List[str] briefings: Dict[str, Any] report: str class EventQueue(list): """Event list that wakes SSE listeners as soon as a node appends an event.""" def __init__(self, *args): super().__init__(*args) self._signal = asyncio.Event() def append(self, item): super().append(item) self._signal.set() async def wait(self, timeout: float) -> None: """Wait until a new event is appended or the timeout passes.""" try: await asyncio.wait_for(self._signal.wait(), timeout) except asyncio.TimeoutError: pass self._signal.clear() # Global job status tracker - shared across application.py and backend nodes job_status = defaultdict[Any, dict[str, str | list[Any] | None]](lambda: { "status": "pending", "result": None, "error": None, "debug_info": [], "company": None, "report": None, "last_update": datetime.now().isoformat(), "events": EventQueue() # Queue for events from parallel nodes })company-research-agent/backend/graph.pycode · 3.9 KB
import logging from typing import Any, AsyncIterator, Dict from langchain_core.messages import SystemMessage from langgraph.graph import StateGraph from .classes.state import InputState from .llm import LLMConfig from .nodes import GroundingNode from .nodes.briefing import Briefing from .nodes.collector import Collector from .nodes.curator import Curator from .nodes.editor import Editor from .nodes.enricher import Enricher from .nodes.researchers import ( CompanyAnalyzer, FinancialAnalyst, IndustryAnalyzer, NewsScanner, ) logger = logging.getLogger(__name__) class Graph: def __init__(self, company=None, url=None, hq_location=None, industry=None, job_id=None, tavily_api_key=None, openrouter_api_key=None, model=None): self.tavily_api_key = tavily_api_key self.llm_config = LLMConfig.resolve(openrouter_api_key, model) # Initialize InputState self.input_state = InputState( company=company, company_url=url, hq_location=hq_location, industry=industry, job_id=job_id, messages=[ SystemMessage(content="Expert researcher starting investigation") ] ) # Initialize nodes self._init_nodes() self._build_workflow() def _init_nodes(self): """Initialize all workflow nodes""" self.ground = GroundingNode() self.financial_analyst = FinancialAnalyst(self.tavily_api_key, self.llm_config) self.news_scanner = NewsScanner(self.tavily_api_key, self.llm_config) self.industry_analyst = IndustryAnalyzer(self.tavily_api_key, self.llm_config) self.company_analyst = CompanyAnalyzer(self.tavily_api_key, self.llm_config) self.collector = Collector() self.curator = Curator() self.enricher = Enricher(self.tavily_api_key) self.briefing = Briefing(self.llm_config) self.editor = Editor(self.llm_config) def _build_workflow(self): """Configure the state graph workflow""" self.workflow = StateGraph(InputState) # Add nodes with their respective processing functions self.workflow.add_node("grounding", self.ground.run) self.workflow.add_node("financial_analyst", self.financial_analyst.run) self.workflow.add_node("news_scanner", self.news_scanner.run) self.workflow.add_node("industry_analyst", self.industry_analyst.run) self.workflow.add_node("company_analyst", self.company_analyst.run) self.workflow.add_node("collector", self.collector.run) self.workflow.add_node("curator", self.curator.run) self.workflow.add_node("enricher", self.enricher.run) self.workflow.add_node("briefing", self.briefing.run) self.workflow.add_node("editor", self.editor.run) # Configure workflow edges self.workflow.set_entry_point("grounding") self.workflow.set_finish_point("editor") research_nodes = [ "financial_analyst", "news_scanner", "industry_analyst", "company_analyst" ] # Connect grounding to all research nodes for node in research_nodes: self.workflow.add_edge("grounding", node) self.workflow.add_edge(node, "collector") # Connect remaining nodes self.workflow.add_edge("collector", "curator") self.workflow.add_edge("curator", "enricher") self.workflow.add_edge("enricher", "briefing") self.workflow.add_edge("briefing", "editor") async def run(self, thread: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: """Execute the research workflow""" compiled_graph = self.workflow.compile() async for state in compiled_graph.astream( self.input_state, thread ): yield state def compile(self): graph = self.workflow.compile() return graphcompany-research-agent/backend/llm.pycode · 2.8 KB
"""LLM provider selection. When an OpenRouter key is available (per request or via OPENROUTER_API_KEY), every node uses the selected OpenRouter model. Otherwise the original direct providers are used: OpenAI for queries and editing, Gemini for briefings (falling back to OpenAI when GEMINI_API_KEY is not set). """ import os from dataclasses import dataclass from typing import Literal from langchain_core.language_models.chat_models import BaseChatModel from langchain_openai import ChatOpenAI OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" DEFAULT_OPENROUTER_MODEL = "google/gemini-3.8-flash" Role = Literal["research", "briefing", "editor"] # Direct-provider models used when OpenRouter is not configured _OPENAI_MODELS: dict[Role, str] = { "research": "gpt-5.1", "briefing": "gpt-5.1", "editor": "gpt-4o", } _GEMINI_BRIEFING_MODEL = "gemini-2.5-flash" @dataclass(frozen=True) class LLMConfig: openrouter_api_key: str | None = None model: str | None = None @classmethod def resolve(cls, openrouter_api_key: str | None = None, model: str | None = None) -> "LLMConfig": """Merge request-supplied settings with environment fallbacks.""" key = (openrouter_api_key or "").strip() or os.getenv("OPENROUTER_API_KEY") model = (model or "").strip() or os.getenv("OPENROUTER_MODEL") or DEFAULT_OPENROUTER_MODEL return cls(openrouter_api_key=key or None, model=model) @property def uses_openrouter(self) -> bool: return bool(self.openrouter_api_key) def create_llm(role: Role, config: LLMConfig | None = None, *, streaming: bool = False) -> BaseChatModel: config = config or LLMConfig.resolve() if config.uses_openrouter: return ChatOpenAI( model=config.model or DEFAULT_OPENROUTER_MODEL, temperature=0, streaming=streaming, api_key=config.openrouter_api_key, base_url=OPENROUTER_BASE_URL, default_headers={ "HTTP-Referer": "https://github.com/guy-hartstein/company-research-agent", "X-Title": "Company Research Agent", }, ) gemini_key = os.getenv("GEMINI_API_KEY") if role == "briefing" and gemini_key: from langchain_google_genai import ChatGoogleGenerativeAI return ChatGoogleGenerativeAI( model=_GEMINI_BRIEFING_MODEL, temperature=0, google_api_key=gemini_key, max_retries=0, ) openai_key = os.getenv("OPENAI_API_KEY") if not openai_key: raise ValueError( "No LLM provider configured. Add an OpenRouter API key in Settings, " "or set OPENROUTER_API_KEY (or OPENAI_API_KEY) in the backend .env file." ) return ChatOpenAI( model=_OPENAI_MODELS[role], temperature=0, streaming=streaming, api_key=openai_key, )company-research-agent/backend/nodes/__init__.pycode · 0.1 KB
from .grounding import GroundingNode __all__ = ["GroundingNode"]company-research-agent/backend/nodes/briefing.pycode · 9.5 KB
import asyncio import logging from typing import Any, Dict, List, Union from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from ..classes import ResearchState from ..classes.state import job_status from ..llm import LLMConfig, create_llm from ..prompts import ( COMPANY_BRIEFING_PROMPT, INDUSTRY_BRIEFING_PROMPT, FINANCIAL_BRIEFING_PROMPT, NEWS_BRIEFING_PROMPT, BRIEFING_ANALYSIS_INSTRUCTION ) logger = logging.getLogger(__name__) class Briefing: """Creates briefings for each research category and updates the ResearchState.""" def __init__(self, llm_config: LLMConfig | None = None) -> None: self.max_doc_length = 8000 # Maximum document content length self.llm = create_llm("briefing", llm_config) def _get_category_prompt(self, category: str) -> str: """Get the category-specific prompt template""" prompts = { 'company': COMPANY_BRIEFING_PROMPT, 'industry': INDUSTRY_BRIEFING_PROMPT, 'financial': FINANCIAL_BRIEFING_PROMPT, 'news': NEWS_BRIEFING_PROMPT, } return prompts.get(category, "Create a focused, informative and insightful research briefing on the company: {company} in the {industry} industry based on the provided documents.") def _prepare_documents(self, docs: Union[Dict[str, Any], List[Dict[str, Any]]]) -> str: """Prepare and format documents for briefing generation""" # Normalize docs to list of (url, doc) tuples items = list(docs.items()) if isinstance(docs, dict) else [ (doc.get('url', f'doc_{i}'), doc) for i, doc in enumerate(docs) ] # Sort by evaluation score sorted_items = sorted( items, key=lambda x: float(x[1].get('evaluation', {}).get('overall_score', '0')), reverse=True ) # Format documents with length limits doc_texts = [] total_length = 0 for _, doc in sorted_items: title = doc.get('title', '') content = doc.get('raw_content') or doc.get('content', '') if len(content) > self.max_doc_length: content = content[:self.max_doc_length] + "... [content truncated]" doc_entry = f"Title: {title}\n\nContent: {content}" if total_length + len(doc_entry) < 120000: # Keep under limit doc_texts.append(doc_entry) total_length += len(doc_entry) else: break separator = "\n" + "-" * 40 + "\n" return f"{separator}{separator.join(doc_texts)}{separator}" async def generate_category_briefing( self, docs: Union[Dict[str, Any], List[Dict[str, Any]]], category: str, context: Dict[str, Any] ): """Generate category briefing and yield events""" company = context.get('company', 'Unknown') industry = context.get('industry', 'Unknown') hq_location = context.get('hq_location', 'Unknown') job_id = context.get('job_id') logger.info(f"Generating {category} briefing for {company} using {len(docs)} documents") # Emit briefing start event event = { "type": "briefing_start", "category": category, "total_docs": len(docs), "step": "Briefing" } if job_id: try: if job_id in job_status: job_status[job_id]["events"].append(event) except Exception as e: logger.error(f"Error appending briefing_start event: {e}") yield event # Get category-specific prompt and prepare documents category_prompt = self._get_category_prompt(category).format( company=company, industry=industry, hq_location=hq_location ) formatted_docs = self._prepare_documents(docs) # Create LCEL chain for briefing generation briefing_prompt = ChatPromptTemplate.from_messages([ ("user", """{category_prompt} {instruction} {documents}""") ]) chain = briefing_prompt | self.llm | StrOutputParser() try: logger.info("Sending prompt to LLM") content = await chain.ainvoke({ "category_prompt": category_prompt, "instruction": BRIEFING_ANALYSIS_INSTRUCTION, "documents": formatted_docs }) if not content: logger.error(f"Empty response from LLM for {category} briefing") yield {"type": "error", "error": "Empty response from LLM", "category": category} yield {'content': ''} return # Emit completion event event = { "type": "briefing_complete", "category": category, "content_length": len(content), "step": "Briefing" } if job_id: try: if job_id in job_status: job_status[job_id]["events"].append(event) except Exception as e: logger.error(f"Error appending briefing_complete event: {e}") yield event yield {'content': content.strip()} except Exception as e: logger.error(f"Error generating {category} briefing: {e}") raise RuntimeError(f"Fatal API error - {category} briefing generation failed: {str(e)}") from e async def create_briefings(self, state: ResearchState) -> ResearchState: """Create briefings for all categories in parallel.""" company = state.get('company', 'Unknown Company') logger.info(f"Creating section briefings for {company}") context = { "company": company, "industry": state.get('industry', 'Unknown'), "hq_location": state.get('hq_location', 'Unknown'), "job_id": state.get('job_id') } # Mapping of curated data fields to briefing categories categories = { 'financial_data': ("financial", "financial_briefing"), 'news_data': ("news", "news_briefing"), 'industry_data': ("industry", "industry_briefing"), 'company_data': ("company", "company_briefing") } briefings = {} # Create tasks for parallel processing briefing_tasks = [] for data_field, (cat, briefing_key) in categories.items(): curated_key = f'curated_{data_field}' curated_data = state.get(curated_key, {}) if curated_data: logger.info(f"Processing {data_field} with {len(curated_data)} documents") briefing_tasks.append({ 'category': cat, 'briefing_key': briefing_key, 'data_field': data_field, 'curated_data': curated_data }) else: logger.info(f"No data available for {data_field}") state[briefing_key] = "" # Process briefings in parallel with rate limiting if briefing_tasks: briefing_semaphore = asyncio.Semaphore(2) # Limit to 2 concurrent briefings async def process_briefing(task: Dict[str, Any]) -> Dict[str, Any]: """Process a single briefing with rate limiting.""" async with briefing_semaphore: result = {'content': ''} # Consume events from briefing generation # Exceptions will propagate immediately (no catching) async for event in self.generate_category_briefing( task['curated_data'], task['category'], context ): if isinstance(event, dict) and 'content' in event: result = event if result['content']: briefings[task['category']] = result['content'] state[task['briefing_key']] = result['content'] logger.info(f"Completed {task['data_field']} briefing ({len(result['content'])} characters)") else: raise RuntimeError(f"Empty briefing generated for {task['data_field']}") return { 'category': task['category'], 'success': bool(result['content']), 'length': len(result['content']) if result['content'] else 0 } # Process all briefings in parallel - exceptions will propagate and kill entire process results = await asyncio.gather(*[ process_briefing(task) for task in briefing_tasks ]) # Log completion statistics successful_briefings = sum(1 for r in results if r['success']) total_length = sum(r['length'] for r in results) logger.info(f"Generated {successful_briefings}/{len(briefing_tasks)} briefings with total length {total_length}") state['briefings'] = briefings return state async def run(self, state: ResearchState) -> ResearchState: return await self.create_briefings(state)company-research-agent/backend/nodes/collector.pycode · 1.2 KB
from langchain_core.messages import AIMessage from ..classes import ResearchState class Collector: """Collects and organizes all research data before curation.""" async def collect(self, state: ResearchState) -> ResearchState: """Collect and verify all research data is present.""" company = state.get('company', 'Unknown Company') msg = [f"📦 Collecting research data for {company}:"] # Check each type of research data research_types = { 'financial_data': '💰 Financial', 'news_data': '📰 News', 'industry_data': '🏭 Industry', 'company_data': '🏢 Company' } for data_field, label in research_types.items(): data = state.get(data_field, {}) if data: msg.append(f"• {label}: {len(data)} documents collected") else: msg.append(f"• {label}: No data found") # Update state with collection message state.setdefault('messages', []).append(AIMessage(content="\n".join(msg))) return state async def run(self, state: ResearchState) -> ResearchState: return await self.collect(state)company-research-agent/backend/nodes/curator.pycode · 6.9 KB
import logging from typing import Dict from urllib.parse import urljoin, urlparse from langchain_core.messages import AIMessage from ..classes import ResearchState from ..classes.state import job_status from ..utils.references import process_references_from_search_results logger = logging.getLogger(__name__) class Curator: def __init__(self) -> None: self.relevance_threshold = 0.4 logger.info(f"Curator initialized with relevance threshold: {self.relevance_threshold}") def evaluate_documents(self, docs: list, context: Dict[str, str]) -> list: """Evaluate documents based on Tavily's scoring.""" if not docs: return [] logger.info(f"Evaluating {len(docs)} documents") evaluated_docs = [] try: # Evaluate each document using Tavily's score for doc in docs: try: # Ensure score is a valid float tavily_score = float(doc.get('score', 0)) # Default to 0 if no score # Always keep company website data regardless of score (first-party information) is_company_website = doc.get('source') == 'company_website' # Keep documents with good Tavily score or company website data if tavily_score >= self.relevance_threshold or is_company_website: reason = "company website" if is_company_website else f"score {tavily_score:.4f}" logger.info(f"Document kept ({reason}) for '{doc.get('title', 'No title')}')") evaluated_doc = { **doc, "evaluation": { "overall_score": tavily_score, # Store as float "query": doc.get('query', '') } } evaluated_docs.append(evaluated_doc) else: logger.info(f"Document below threshold with score {tavily_score:.4f} for '{doc.get('title', 'No title')}'") except (ValueError, TypeError) as e: logger.warning(f"Error processing score for document: {e}") continue except Exception as e: logger.error(f"Error during document evaluation: {e}") return [] # Sort evaluated docs by score before returning evaluated_docs.sort(key=lambda x: float(x['evaluation']['overall_score']), reverse=True) logger.info(f"Returning {len(evaluated_docs)} evaluated documents") return evaluated_docs async def curate_data(self, state: ResearchState) -> ResearchState: """Curate all collected data based on Tavily scores.""" company = state.get('company', 'Unknown Company') job_id = state.get('job_id') logger.info(f"Starting curation for company: {company}, job_id={job_id}") industry = state.get('industry', 'Unknown') context = { "company": company, "industry": industry, "hq_location": state.get('hq_location', 'Unknown') } msg = [f"🔍 Curating research data for {company}"] data_types = { 'financial_data': ('💰 Financial', 'financial'), 'news_data': ('📰 News', 'news'), 'industry_data': ('🏭 Industry', 'industry'), 'company_data': ('🏢 Company', 'company') } # Process each data type for data_field, (emoji, doc_type) in data_types.items(): data = state.get(data_field, {}) if not data: continue # Filter and normalize URLs unique_docs = {} for url, doc in data.items(): try: parsed = urlparse(url) if not parsed.scheme: url = urljoin('https://', url) clean_url = parsed._replace(query='', fragment='').geturl() if clean_url not in unique_docs: doc['url'] = clean_url doc['doc_type'] = doc_type unique_docs[clean_url] = doc except Exception: continue docs = list(unique_docs.values()) msg.append(f"\n{emoji}: Found {len(docs)} documents") evaluated_docs = self.evaluate_documents(docs, context) # Emit curation event with total count if job_id: try: if job_id in job_status: job_status[job_id]["events"].append({ "type": "curation", "category": doc_type, "total": len(evaluated_docs) if evaluated_docs else 0, "message": f"Curating {doc_type} documents" }) except Exception as e: logger.error(f"Error appending curation event: {e}") if not evaluated_docs: msg.append(" ⚠️ No relevant documents found") continue # Filter and sort by Tavily score relevant_docs = {doc['url']: doc for doc in evaluated_docs} sorted_items = sorted(relevant_docs.items(), key=lambda item: item[1]['evaluation']['overall_score'], reverse=True) # Fast mode: enrich only the strongest sources in each category. if len(sorted_items) > 5: sorted_items = sorted_items[:5] relevant_docs = dict(sorted_items) if relevant_docs: msg.append(f" ✓ Kept {len(relevant_docs)} relevant documents") logger.info(f"Kept {len(relevant_docs)} documents for {doc_type} with scores above threshold") else: msg.append(" ⚠️ No documents met relevance threshold") logger.info(f"No documents met relevance threshold for {doc_type}") # Store curated documents in state state[f'curated_{data_field}'] = relevant_docs # Process references using the references module top_reference_urls, reference_titles, reference_info = process_references_from_search_results(state) logger.info(f"Selected top {len(top_reference_urls)} references for the report") # Update state with references and their titles state.setdefault('messages', []).append(AIMessage(content="\n".join(msg))) state['references'] = top_reference_urls state['reference_titles'] = reference_titles state['reference_info'] = reference_info return state async def run(self, state: ResearchState) -> ResearchState: return await self.curate_data(state)company-research-agent/backend/nodes/editor.pycode · 9.1 KB
import logging from typing import Dict from langchain_core.messages import AIMessage from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from ..classes import ResearchState from ..classes.state import job_status from ..llm import LLMConfig, create_llm from ..utils.references import format_references_section from ..prompts import ( EDITOR_SYSTEM_MESSAGE, COMPILE_CONTENT_PROMPT, CONTENT_SWEEP_SYSTEM_MESSAGE, CONTENT_SWEEP_PROMPT ) logger = logging.getLogger(__name__) class Editor: """Compiles individual section briefings into a cohesive final report.""" def __init__(self, llm_config: LLMConfig | None = None) -> None: self.llm = create_llm("editor", llm_config, streaming=True) # Initialize context dictionary self.context = { "company": "Unknown Company", "industry": "Unknown", "hq_location": "Unknown" } async def compile_briefings(self, state: ResearchState) -> ResearchState: """Compile individual briefing categories from state into a final report.""" company = state.get('company', 'Unknown Company') job_id = state.get('job_id') # Update context with values from state self.context = { "company": company, "industry": state.get('industry', 'Unknown'), "hq_location": state.get('hq_location', 'Unknown') } msg = [f"📑 Compiling final report for {company}..."] # Emit report compilation start event if job_id: try: if job_id in job_status: job_status[job_id]["events"].append({ "type": "report_compilation", "message": f"Compiling final report for {company}" }) except Exception as e: logger.error(f"Error appending report_compilation event: {e}") # Pull individual briefings from dedicated state keys briefing_keys = { 'company': 'company_briefing', 'industry': 'industry_briefing', 'financial': 'financial_briefing', 'news': 'news_briefing' } individual_briefings = {} for category, key in briefing_keys.items(): if content := state.get(key): individual_briefings[category] = content msg.append(f"Found {category} briefing ({len(content)} characters)") else: msg.append(f"No {category} briefing available") logger.error(f"Missing state key: {key}") if not individual_briefings: msg.append("\n⚠️ No briefing sections available to compile") logger.error("No briefings found in state") else: try: compiled_report = await self.edit_report(state, individual_briefings) if not compiled_report or not compiled_report.strip(): logger.error("Compiled report is empty!") else: logger.info(f"Successfully compiled report with {len(compiled_report)} characters") except Exception as e: logger.error(f"Error during report compilation: {e}") state.setdefault('messages', []).append(AIMessage(content="\n".join(msg))) return state async def edit_report(self, state: ResearchState, briefings: Dict[str, str]) -> str: """Compile section briefings into a final report and update the state.""" try: logger.info("Starting report compilation") job_id = state.get('job_id') # Step 1: Initial Compilation edited_report = await self.compile_content(state, briefings) if not edited_report: logger.error("Initial compilation failed") return "" # Step 2 & 3: Content sweep and streaming final_report = "" async for event in self.content_sweep(edited_report): # Forward streaming events to job_status if isinstance(event, dict) and job_id: try: if job_id in job_status: job_status[job_id]["events"].append(event) logger.debug(f"Appended report_chunk event ({len(event.get('chunk', ''))} chars)") except Exception as e: logger.error(f"Error appending report_chunk event: {e}") # Accumulate the text if isinstance(event, str): final_report = event final_report = final_report or edited_report or "" logger.info(f"Final report compiled with {len(final_report)} characters") if not final_report.strip(): logger.error("Final report is empty!") return "" # Update state with the final report state['report'] = final_report state['status'] = "editor_complete" if 'editor' not in state or not isinstance(state['editor'], dict): state['editor'] = {} state['editor']['report'] = final_report return final_report except Exception as e: logger.error(f"Error in edit_report: {e}") return "" async def compile_content(self, state: ResearchState, briefings: Dict[str, str]) -> str: """Initial compilation of research sections using LCEL.""" combined_content = "\n\n".join(content for content in briefings.values()) references = state.get('references', []) reference_text = "" if references: logger.info(f"Found {len(references)} references to add during compilation") reference_info = state.get('reference_info', {}) reference_titles = state.get('reference_titles', {}) reference_text = format_references_section(references, reference_info, reference_titles) logger.info(f"Added {len(references)} references during compilation") # Create LCEL chain for compilation compile_prompt = ChatPromptTemplate.from_messages([ ("system", EDITOR_SYSTEM_MESSAGE), ("user", COMPILE_CONTENT_PROMPT) ]) chain = compile_prompt | self.llm | StrOutputParser() try: initial_report = await chain.ainvoke({ "company": self.context["company"], "industry": self.context["industry"], "hq_location": self.context["hq_location"], "combined_content": combined_content }) # Append references section if reference_text: initial_report = f"{initial_report}\n\n{reference_text}" return initial_report except Exception as e: logger.error(f"Error in initial compilation: {e}") return combined_content or "" async def content_sweep(self, content: str): """Sweep the content for any redundant information using LCEL streaming and yield events.""" # Create LCEL chain for content sweep sweep_prompt = ChatPromptTemplate.from_messages([ ("system", CONTENT_SWEEP_SYSTEM_MESSAGE), ("user", CONTENT_SWEEP_PROMPT) ]) chain = sweep_prompt | self.llm | StrOutputParser() try: accumulated_text = "" buffer = "" # Stream using LangChain's astream async for chunk in chain.astream({ "company": self.context["company"], "industry": self.context["industry"], "hq_location": self.context["hq_location"], "content": content }): accumulated_text += chunk buffer += chunk # Yield chunks at sentence boundaries if any(char in buffer for char in ['.', '!', '?', '\n']) and len(buffer) > 10: yield {"type": "report_chunk", "chunk": buffer, "step": "Editor"} buffer = "" # Yield final buffer if buffer: yield {"type": "report_chunk", "chunk": buffer, "step": "Editor"} yield accumulated_text.strip() except Exception as e: logger.error(f"Error in formatting: {e}") yield {"type": "error", "error": str(e), "step": "Editor"} yield content or "" async def run(self, state: ResearchState) -> ResearchState: state = await self.compile_briefings(state) # Ensure the Editor node's output is stored both top-level and under "editor" if 'report' in state: if 'editor' not in state or not isinstance(state['editor'], dict): state['editor'] = {} state['editor']['report'] = state['report'] return statecompany-research-agent/backend/nodes/enricher.pycode · 8.6 KB
import asyncio import logging import os from typing import Dict, List from langchain_core.messages import AIMessage from tavily import AsyncTavilyClient from ..classes import ResearchState from ..classes.state import job_status logger = logging.getLogger(__name__) class Enricher: """Enriches curated documents with raw content.""" def __init__(self, tavily_api_key: str | None = None) -> None: tavily_key = tavily_api_key or os.getenv("TAVILY_API_KEY") if not tavily_key: raise ValueError("TAVILY_API_KEY environment variable is not set") self.tavily_client = AsyncTavilyClient(api_key=tavily_key) self.batch_size = 20 async def fetch_single_content(self, url: str) -> Dict[str, str]: """Fetch raw content for a single URL.""" try: result = await self.tavily_client.extract(url, extract_depth="advanced") if result and result.get('results'): return {url: result['results'][0].get('raw_content', '')} except Exception as e: logger.error(f"Error fetching raw content for {url}: {e}") return {url: ''} return {url: ''} async def fetch_raw_content(self, urls: List[str], on_enriched=None) -> Dict[str, str]: """Fetch raw content for multiple URLs in parallel with rate limiting. on_enriched is called each time a URL returns non-empty content. """ raw_contents = {} # Create batches batches = [urls[i:i + self.batch_size] for i in range(0, len(urls), self.batch_size)] # Process batches with rate limiting semaphore = asyncio.Semaphore(3) # Limit concurrent batches to 3 async def process_batch(batch_urls: List[str]) -> Dict[str, str]: async with semaphore: async def fetch_and_report(url: str) -> Dict[str, str]: result = await self.fetch_single_content(url) if on_enriched and result.get(url): on_enriched() return result results = await asyncio.gather(*[fetch_and_report(url) for url in batch_urls]) batch_contents = {} for result in results: batch_contents.update(result) return batch_contents # Process all batches batch_results = await asyncio.gather(*[process_batch(batch) for batch in batches]) # Combine results from all batches for batch_result in batch_results: raw_contents.update(batch_result) return raw_contents async def enrich_data(self, state: ResearchState) -> ResearchState: """Enrich curated documents with raw content.""" company = state.get('company', 'Unknown Company') job_id = state.get('job_id') logger.info(f"Starting enrichment for company: {company}, job_id={job_id}") msg = [f"📚 Enriching curated data for {company}:"] # Process each type of curated data data_types = { 'financial_data': '💰 Financial', 'news_data': '📰 News', 'industry_data': '🏭 Industry', 'company_data': '🏢 Company' } # Create tasks for parallel processing enrichment_tasks = [] for data_field, label in data_types.items(): curated_field = f'curated_{data_field}' curated_docs = state.get(curated_field, {}) if not curated_docs: msg.append(f"\n• No curated {label} documents to enrich") continue # Find documents needing enrichment docs_needing_content = {url: doc for url, doc in curated_docs.items() if not doc.get('raw_content')} if not docs_needing_content: msg.append(f"\n• All {label} documents already have raw content") continue msg.append(f"\n• Enriching {len(docs_needing_content)} {label} documents...") # Extract category name from field (e.g., 'curated_financial_data' -> 'financial') category = curated_field.replace('curated_', '').replace('_data', '') enrichment_tasks.append({ 'field': curated_field, 'label': label, 'category': category, 'docs': docs_needing_content, 'curated_docs': curated_docs }) # Emit enrichment start event if enrichment_tasks and job_id: try: if job_id in job_status: job_status[job_id]["events"].append({ "type": "enrichment", "message": f"Enriching {len(enrichment_tasks)} categories" }) except Exception as e: logger.error(f"Error appending enrichment event: {e}") # Process all categories in parallel if enrichment_tasks: async def process_category(task): try: progress = {'enriched': 0} total = len(task['docs']) def report_progress(): # Stream per-document progress so the UI counts up as extracts land progress['enriched'] += 1 if job_id and job_id in job_status: job_status[job_id]["events"].append({ "type": "enrichment", "category": task['category'], "enriched": progress['enriched'], "total": total, "message": f"Enriched {progress['enriched']}/{total} {task['label']} documents" }) raw_contents = await self.fetch_raw_content(list(task['docs'].keys()), report_progress) enriched_count = 0 for url, content in raw_contents.items(): if content: # Only add non-empty content task['curated_docs'][url]['raw_content'] = content enriched_count += 1 # Update state with enriched documents state[task['field']] = task['curated_docs'] return { 'label': task['label'], 'category': task['category'], 'enriched': enriched_count, 'total': len(task['docs']) } except Exception as e: logger.error(f"Error processing category {task['label']}: {e}") return { 'label': task['label'], 'category': task['category'], 'enriched': 0, 'total': len(task['docs']) } # Process all categories in parallel results = await asyncio.gather(*[process_category(task) for task in enrichment_tasks]) # Add summary to message and emit enrichment completion events for result in results: msg.append(f"\n ✓ {result['label']}: {result['enriched']}/{result['total']} documents enriched") # Emit enrichment completion event for each category if job_id: try: if job_id in job_status: job_status[job_id]["events"].append({ "type": "enrichment", "category": result['category'], # Use category instead of label "enriched": result['enriched'], "total": result['total'], "message": f"Enriched {result['enriched']}/{result['total']} {result['label']} documents" }) except Exception as e: logger.error(f"Error appending enrichment completion event: {e}") # Update state with enrichment message state.setdefault('messages', []).append(AIMessage(content="\n".join(msg))) return state async def run(self, state: ResearchState) -> ResearchState: try: return await self.enrich_data(state) except Exception as e: logger.error(f"Error in enrichment process: {e}") return statecompany-research-agent/backend/nodes/grounding.pycode · 2.7 KB
import logging from langchain_core.messages import AIMessage from ..classes import InputState, ResearchState from ..classes.state import job_status logger = logging.getLogger(__name__) class GroundingNode: """Gathers initial grounding data about the company.""" async def initial_search(self, state: InputState): """Initial search and yield events""" company = state.get('company', 'Unknown Company') job_id = state.get('job_id') msg = f"🎯 Initiating research for {company}...\n" # Emit initialization event event = { "type": "research_init", "company": company, "message": f"Initiating research for {company}", "step": "Initializing" } if job_id: try: if job_id in job_status: job_status[job_id]["events"].append(event) except Exception as e: logger.error(f"Error appending research_init event: {e}") yield event # Fast mode relies on targeted search and skips the potentially large # company-site crawl. The submitted URL is retained in the job state. site_scrape = {} # Add context about what information we have if hq := state.get('hq_location'): msg += f"\n📍 Company HQ: {hq}" if industry := state.get('industry'): msg += f"\n🏭 Industry: {industry}" # Initialize ResearchState with input information research_state = { # Copy input fields "company": state.get('company'), "company_url": state.get('company_url'), "hq_location": state.get('hq_location'), "industry": state.get('industry'), "job_id": state.get('job_id'), # Initialize research fields "messages": [AIMessage(content=msg)], "site_scrape": site_scrape } yield {"type": "grounding_complete", "site_pages": len(site_scrape)} yield research_state async def run(self, state: InputState) -> ResearchState: """Run grounding - note: for now returns directly, events can be captured if needed""" # For compatibility, we call the generator but don't yield # The calling code can be updated later to consume events result = None async for event in self.initial_search(state): # The last yield should be the research_state (a dict with state fields) # Earlier yields are event dicts with "type" field if isinstance(event, dict) and "type" not in event: result = event return result if result else {}company-research-agent/backend/nodes/researchers/__init__.pycode · 0.2 KB
from .financial import FinancialAnalyst from .news import NewsScanner from .industry import IndustryAnalyzer from .company import CompanyAnalyzer __all__ = ["FinancialAnalyst", "NewsScanner", "IndustryAnalyzer", "CompanyAnalyzer"]company-research-agent/backend/nodes/researchers/base.pycode · 9.3 KB
import asyncio import logging import os from datetime import datetime from typing import Any, Dict, List from langchain_core.prompts import ChatPromptTemplate from tavily import AsyncTavilyClient from ...classes import ResearchState from ...classes.state import job_status from ...llm import LLMConfig, create_llm from ...utils.references import clean_title from ...prompts import QUERY_FORMAT_GUIDELINES logger = logging.getLogger(__name__) class BaseResearcher: def __init__(self, tavily_api_key: str | None = None, llm_config: LLMConfig | None = None): tavily_key = tavily_api_key or os.getenv("TAVILY_API_KEY") if not tavily_key: raise ValueError("Missing Tavily API key") self.tavily_client = AsyncTavilyClient(api_key=tavily_key) self.llm = create_llm("research", llm_config, streaming=True) self.analyst_type = "base_researcher" @property def analyst_type(self) -> str: if not hasattr(self, '_analyst_type'): raise ValueError("Analyst type not set by subclass") return self._analyst_type @analyst_type.setter def analyst_type(self, value: str): self._analyst_type = value async def generate_queries(self, state: Dict, prompt: str): """Generate search queries and yield events as they're created""" company = state.get("company", "Unknown Company") industry = state.get("industry", "Unknown Industry") hq_location = state.get("hq_location", "Unknown") current_year = datetime.now().year job_id = state.get("job_id") logger.info(f"=== GENERATE_QUERIES START: job_id={job_id}, analyst={self.analyst_type} ===") if not job_id: logger.warning(f"⚠️ NO JOB_ID in state! Keys: {list(state.keys())}") try: logger.info(f"Generating queries for {company} as {self.analyst_type}, job_id={job_id}") # Create prompt template using LangChain query_prompt = ChatPromptTemplate.from_messages([ ("system", "You are researching {company}, a company in the {industry} industry, headquartered in {hq_location}."), ("user", """Researching {company} in {year}, as of {date}. {task_prompt} {format_guidelines}""") ]) # Create LCEL chain chain = query_prompt | self.llm queries = [] current_query = "" current_query_number = 1 # Stream queries using LangChain's astream async for chunk in chain.astream({ "company": company, "industry": industry, "hq_location": hq_location, "year": current_year, "date": datetime.now().strftime("%B %d, %Y"), "task_prompt": prompt, "format_guidelines": QUERY_FORMAT_GUIDELINES.format(company=company) }): current_query += chunk.content # Yield query generation progress event = { "type": "query_generating", "query": current_query, "query_number": current_query_number, "category": self.analyst_type } # Update job status if job_id provided if job_id: try: if job_id in job_status: job_status[job_id]["events"].append(event) else: logger.warning(f"job_id {job_id} not found in job_status. Available keys: {list(job_status.keys())[:3]}") except Exception as e: logger.error(f"Error appending event: {e}") yield event # Parse completed queries on newline if '\n' in current_query: parts = current_query.split('\n') current_query = parts[-1] for query in parts[:-1]: query = query.strip() if query: queries.append(query) event = { "type": "query_generated", "query": query, "query_number": len(queries), "category": self.analyst_type } # Update job status if job_id provided if job_id: try: if job_id in job_status: job_status[job_id]["events"].append(event) else: logger.warning(f"job_id {job_id} not found in job_status for query_generated") except Exception as e: logger.error(f"Error appending query_generated event: {e}") yield event current_query_number += 1 # Add remaining query if current_query.strip(): queries.append(current_query.strip()) event = { "type": "query_generated", "query": current_query.strip(), "query_number": len(queries), "category": self.analyst_type } if job_id and job_id in job_status: job_status[job_id]["events"].append(event) yield event if not queries: raise ValueError(f"No queries generated for {company}") queries = queries[:2] # Fast mode: two focused queries per analyst logger.info(f"Final queries for {self.analyst_type}: {queries}") yield {"type": "queries_complete", "queries": queries, "count": len(queries)} except Exception as e: logger.error(f"Error generating queries for {company}: {e}") raise RuntimeError(f"Fatal API error - query generation failed: {str(e)}") from e def _get_search_params(self) -> Dict[str, Any]: """Get search parameters based on analyst type""" params = { "search_depth": "advanced", "include_raw_content": False, "max_results": 3 } topic_map = { "news_analyzer": "news", "financial_analyzer": "finance" } if topic := topic_map.get(self.analyst_type): params["topic"] = topic return params def _process_search_result(self, result: Dict[str, Any], query: str) -> Dict[str, Any]: """Process a single search result into standardized format""" if not result.get("content") or not result.get("url"): return {} url = result.get("url") title = clean_title(result.get("title", "")) if result.get("title") else "" # Reset empty or invalid titles if not title or title.lower() == url.lower(): title = "" return { "title": title, "content": result.get("content", ""), "query": query, "url": url, "source": "web_search", "score": result.get("score", 0.0) } async def search_documents(self, state: ResearchState, queries: List[str]): """Execute all Tavily searches in parallel and yield events""" if not queries: logger.error("No valid queries to search") yield {"type": "error", "error": "No valid queries to search"} return # Yield start event yield { "type": "search_started", "message": f"Searching {len(queries)} queries", "total_queries": len(queries) } # Execute all searches in parallel search_params = self._get_search_params() search_tasks = [self.tavily_client.search(query, **search_params) for query in queries] try: results = await asyncio.gather(*search_tasks, return_exceptions=True) except Exception as e: logger.error(f"Error during parallel search execution: {e}") yield {"type": "error", "error": str(e)} return # Process and merge results merged_docs = {} for query, result in zip(queries, results): if isinstance(result, Exception): logger.error(f"Search failed for query '{query}': {result}") yield {"type": "query_error", "query": query, "error": str(result)} continue for item in result.get("results", []): if doc := self._process_search_result(item, query): merged_docs[doc["url"]] = doc # Yield completion event yield { "type": "search_complete", "message": f"Found {len(merged_docs)} documents", "total_documents": len(merged_docs), "queries_processed": len(queries), "merged_docs": merged_docs }company-research-agent/backend/nodes/researchers/company.pycode · 2.3 KB
from typing import Any from langchain_core.messages import AIMessage from ...classes import ResearchState from ...prompts import COMPANY_ANALYZER_QUERY_PROMPT from ...llm import LLMConfig from .base import BaseResearcher class CompanyAnalyzer(BaseResearcher): def __init__(self, tavily_api_key: str | None = None, llm_config: LLMConfig | None = None) -> None: super().__init__(tavily_api_key, llm_config) self.analyst_type = "company_analyzer" async def analyze(self, state: ResearchState): """Analyze company and yield events""" company = state.get('company', 'Unknown Company') # Generate search queries and yield events queries = [] async for event in self.generate_queries(state, COMPANY_ANALYZER_QUERY_PROMPT): yield event if event.get("type") == "queries_complete": queries = event.get("queries", []) # Log subqueries subqueries_msg = "🔍 Subqueries for company analysis:\n" + "\n".join([f"• {query}" for query in queries]) state.setdefault('messages', []).append(AIMessage(content=subqueries_msg)) # Start with site scrape data company_data = dict[str, Any](state.get('site_scrape', {})) # Search and merge documents, yielding events documents = {} async for event in self.search_documents(state, queries): yield event if event.get("type") == "search_complete": documents = event.get("merged_docs", {}) company_data.update(documents) # Update state completion_msg = f"🏢 Company Analyzer found {len(company_data)} documents for {company}" state.setdefault('messages', []).append(AIMessage(content=completion_msg)) state['company_data'] = company_data yield {"type": "analysis_complete", "data_type": "company_data", "count": len(company_data)} yield {'message': [completion_msg], 'company_data': company_data} async def run(self, state: ResearchState): """Run analysis and yield all events""" result = None async for event in self.analyze(state): yield event if "message" in event or "company_data" in event: result = event yield result or {}company-research-agent/backend/nodes/researchers/financial.pycode · 2.3 KB
from langchain_core.messages import AIMessage from ...classes import ResearchState from ...prompts import FINANCIAL_ANALYZER_QUERY_PROMPT from ...llm import LLMConfig from .base import BaseResearcher class FinancialAnalyst(BaseResearcher): def __init__(self, tavily_api_key: str | None = None, llm_config: LLMConfig | None = None) -> None: super().__init__(tavily_api_key, llm_config) self.analyst_type = "financial_analyzer" async def analyze(self, state: ResearchState): """Analyze financials and yield events""" company = state.get('company', 'Unknown Company') # Generate search queries and yield events queries = [] async for event in self.generate_queries(state, FINANCIAL_ANALYZER_QUERY_PROMPT): yield event if event.get("type") == "queries_complete": queries = event.get("queries", []) # Log subqueries subqueries_msg = "🔍 Subqueries for financial analysis:\n" + "\n".join([f"• {query}" for query in queries]) state.setdefault('messages', []).append(AIMessage(content=subqueries_msg)) # Start with site scrape data financial_data = dict(state.get('site_scrape', {})) # Search and merge documents, yielding events documents = {} async for event in self.search_documents(state, queries): yield event if event.get("type") == "search_complete": documents = event.get("merged_docs", {}) financial_data.update(documents) # Update state completion_msg = f"💰 Financial Analyst found {len(financial_data)} documents for {company}" state.setdefault('messages', []).append(AIMessage(content=completion_msg)) state['financial_data'] = financial_data yield {"type": "analysis_complete", "data_type": "financial_data", "count": len(financial_data)} yield {'message': [completion_msg], 'financial_data': financial_data} async def run(self, state: ResearchState): """Run analysis and yield all events""" result = None async for event in self.analyze(state): yield event if "message" in event or "financial_data" in event: result = event yield result or {}company-research-agent/backend/nodes/researchers/industry.pycode · 2.4 KB
from langchain_core.messages import AIMessage from ...classes import ResearchState from ...prompts import INDUSTRY_ANALYZER_QUERY_PROMPT from ...llm import LLMConfig from .base import BaseResearcher class IndustryAnalyzer(BaseResearcher): def __init__(self, tavily_api_key: str | None = None, llm_config: LLMConfig | None = None) -> None: super().__init__(tavily_api_key, llm_config) self.analyst_type = "industry_analyzer" async def analyze(self, state: ResearchState): """Analyze industry and yield events""" company = state.get('company', 'Unknown Company') industry = state.get('industry', 'Unknown Industry') # Generate search queries and yield events queries = [] async for event in self.generate_queries(state, INDUSTRY_ANALYZER_QUERY_PROMPT): yield event if event.get("type") == "queries_complete": queries = event.get("queries", []) # Log subqueries subqueries_msg = "🔍 Subqueries for industry analysis:\n" + "\n".join([f"• {query}" for query in queries]) state.setdefault('messages', []).append(AIMessage(content=subqueries_msg)) # Start with site scrape data industry_data = dict(state.get('site_scrape', {})) # Search and merge documents, yielding events documents = {} async for event in self.search_documents(state, queries): yield event if event.get("type") == "search_complete": documents = event.get("merged_docs", {}) industry_data.update(documents) # Update state completion_msg = f"🏭 Industry Analyzer found {len(industry_data)} documents for {company} in {industry}" state.setdefault('messages', []).append(AIMessage(content=completion_msg)) state['industry_data'] = industry_data yield {"type": "analysis_complete", "data_type": "industry_data", "count": len(industry_data)} yield {'message': [completion_msg], 'industry_data': industry_data} async def run(self, state: ResearchState): """Run analysis and yield all events""" result = None async for event in self.analyze(state): yield event if "message" in event or "industry_data" in event: result = event yield result or {}company-research-agent/backend/nodes/researchers/news.pycode · 2.3 KB
from typing import Any from langchain_core.messages import AIMessage from ...classes import ResearchState from ...prompts import NEWS_SCANNER_QUERY_PROMPT from ...llm import LLMConfig from .base import BaseResearcher class NewsScanner(BaseResearcher): def __init__(self, tavily_api_key: str | None = None, llm_config: LLMConfig | None = None) -> None: super().__init__(tavily_api_key, llm_config) self.analyst_type = "news_analyzer" async def analyze(self, state: ResearchState): """Analyze news and yield events""" company = state.get('company', 'Unknown Company') # Generate search queries and yield events queries = [] async for event in self.generate_queries(state, NEWS_SCANNER_QUERY_PROMPT): yield event if event.get("type") == "queries_complete": queries = event.get("queries", []) # Log subqueries subqueries_msg = "🔍 Subqueries for news analysis:\n" + "\n".join([f"• {query}" for query in queries]) state.setdefault('messages', []).append(AIMessage(content=subqueries_msg)) # Start with site scrape data news_data = dict[str, Any](state.get('site_scrape', {})) # Search and merge documents, yielding events documents = {} async for event in self.search_documents(state, queries): yield event if event.get("type") == "search_complete": documents = event.get("merged_docs", {}) news_data.update(documents) # Update state completion_msg = f"📰 News Scanner found {len(news_data)} documents for {company}" state.setdefault('messages', []).append(AIMessage(content=completion_msg)) state['news_data'] = news_data yield {"type": "analysis_complete", "data_type": "news_data", "count": len(news_data)} yield {'message': [completion_msg], 'news_data': news_data} async def run(self, state: ResearchState): """Run analysis and yield all events""" result = None async for event in self.analyze(state): yield event if "message" in event or "news_data" in event: result = event yield result or {}company-research-agent/backend/prompts.pycode · 7.9 KB
""" Centralized prompts for the company research agent. All LLM prompts are defined here for easy maintenance and updates. """ # ============================================================================ # BRIEFING PROMPTS # ============================================================================ COMPANY_BRIEFING_PROMPT = """Create a focused, yet comprehensive company briefing for {company}, a {industry} company based in {hq_location}. Key requirements: 1. Start with: "{company} is a [what] that [does what] for [whom]" 2. Structure using these headers and bullet points: ### Core Product/Service * List distinct products/features * Include only verified technical capabilities ### Leadership Team * List key leadership team members * Include their roles and expertise ### Target Market * List specific target audiences * List verified use cases * List confirmed customers/partners ### Key Differentiators * List unique features * List proven advantages ### Business Model * Discuss product / service pricing * List distribution channels 3. Each bullet must be a single, complete fact 4. Never mention "no information found" or "no data available" 5. No paragraphs, only bullet points 6. Provide only the briefing. No explanations or commentary.""" INDUSTRY_BRIEFING_PROMPT = """Create a focused, yet comprehensive industry briefing for {company}, a {industry} company based in {hq_location}. Key requirements: 1. Structure using these exact headers and bullet points: ### Market Overview * State {company}'s exact market segment * List market size with year * List growth rate with year range ### Direct Competition * List named direct competitors * List specific competing products * List market positions ### Competitive Advantages • List unique technical features • List proven advantages ### Market Challenges • List specific verified challenges 2. Each bullet must be a single, complete news event. 3. No paragraphs, only bullet points 4. Never mention "no information found" or "no data available" 5. Provide only the briefing. No explanation.""" FINANCIAL_BRIEFING_PROMPT = """Create a focused, yet comprehensive financial briefing for {company}, a {industry} company based in {hq_location}. Key requirements: 1. Structure using these headers and bullet points: ### Funding & Investment * Total funding amount with date * List each funding round with date * List named investors ### Revenue Model * Discuss product / service pricing if applicable 2. Include specific numbers when possible 3. No paragraphs, only bullet points 4. Never mention "no information found" or "no data available" 5. NEVER repeat the same round of funding multiple times. ALWAYS assume that multiple funding rounds in the same month are the same round. 6. NEVER include a range of funding amounts. Use your best judgement to determine the exact amount based on the information provided. 6. Provide only the briefing. No explanation or commentary.""" NEWS_BRIEFING_PROMPT = """Create a focused, yet comprehensive news briefing for {company}, a {industry} company based in {hq_location}. Key requirements: 1. Structure into these categories using bullet points: ### Major Announcements * Product / service launches * New initiatives ### Partnerships * Integrations * Collaborations ### Recognition * Awards * Press coverage 2. Sort newest to oldest 3. One event per bullet point 4. Do not mention "no information found" or "no data available" 5. Never use ### headers, only bullet points 6. Provide only the briefing. Do not provide explanations or commentary.""" BRIEFING_ANALYSIS_INSTRUCTION = """Analyze the following documents and extract key information. Provide only the briefing, no explanations or commentary:""" # ============================================================================ # EDITOR PROMPTS # ============================================================================ EDITOR_SYSTEM_MESSAGE = "You are an expert report editor that compiles research briefings into comprehensive company reports." COMPILE_CONTENT_PROMPT = """You are compiling a comprehensive research report about {company}. Compiled briefings: {combined_content} Create a deep, comprehensive, and thorough report on {company}, a {industry} company headquartered in {hq_location} that: 1. Integrates information from all sections into a cohesive non-repetitive narrative 2. Maintains important details from each section 3. Logically organizes information and removes transitional commentary / explanations 4. Uses clear section headers and structure Formatting rules: Strictly enforce this EXACT document structure: # {company} Research Report ## Company Overview [Company content with ### subsections] ## Industry Overview [Industry content with ### subsections] ## Financial Overview [Financial content with ### subsections] ## News [News content with ### subsections] Return the report in clean markdown format. No explanations or commentary.""" CONTENT_SWEEP_SYSTEM_MESSAGE = "You are an expert markdown formatter that ensures consistent document structure." CONTENT_SWEEP_PROMPT = """You are an expert briefing editor. You are given a report on {company}. Current report: {content} 1. Remove redundant or repetitive information 2. Remove information that is not relevant to {company}, the {industry} company headquartered in {hq_location}. 3. Remove sections lacking substantial content 4. Remove any meta-commentary (e.g. "Here is the news...") Strictly enforce this document structure: ## Company Overview [Company content with ### subsections] ## Industry Overview [Industry content with ### subsections] ## Financial Overview [Financial content with ### subsections] ## News [News content with ### subsections] ## References [References in MLA format - PRESERVE EXACTLY AS PROVIDED] Critical rules: 1. The document MUST start with "# {company} Research Report" 2. The document MUST ONLY use these exact ## headers in this order: - ## Company Overview - ## Industry Overview - ## Financial Overview - ## News - ## References 3. NO OTHER ## HEADERS ARE ALLOWED 4. Use ### for subsections in Company/Industry/Financial sections 5. News section should only use bullet points (*), never headers 6. Never use code blocks (```) 7. Never use more than one blank line between sections 8. Format all bullet points with * 9. Add one blank line before and after each section/list 10. DO NOT CHANGE the format of the references section Return the polished report in flawless markdown format. No explanation. Return the cleaned report in flawless markdown format. No explanations or commentary.""" # ============================================================================ # RESEARCH QUERY GENERATION PROMPTS # ============================================================================ COMPANY_ANALYZER_QUERY_PROMPT = """Generate queries on the company fundamentals of {company} in the {industry} industry such as: - Core products and services - Company history and milestones - Leadership team - Business model and strategy """ FINANCIAL_ANALYZER_QUERY_PROMPT = """Generate queries on the financial analysis of {company} in the {industry} industry such as: - Fundraising history and valuation - Financial statements and key metrics - Revenue and profit sources """ INDUSTRY_ANALYZER_QUERY_PROMPT = """Generate queries on the industry analysis of {company} in the {industry} industry such as: - Market position - Competitors - {industry} industry trends and challenges - Market size and growth """ NEWS_SCANNER_QUERY_PROMPT = """Generate queries on the recent news coverage of {company} such as: - Recent company announcements - Press releases - New partnerships """ QUERY_FORMAT_GUIDELINES = """ Important Guidelines: - Focus ONLY on {company}-specific information - Make queries very brief and to the point - Provide exactly 2 search queries (one per line), with no hyphens or dashes - DO NOT make assumptions about the industry - use only the provided industry information"""company-research-agent/backend/services/mongodb.pycode · 2.2 KB
from datetime import datetime from typing import Any, Dict, Optional import certifi from pymongo import MongoClient class MongoDBService: def __init__(self, uri: str): # Use certifi for SSL certificate verification with updated options self.client = MongoClient( uri, tlsCAFile=certifi.where(), retryWrites=True, w='majority' ) self.db = self.client.get_database('tavily_research') self.jobs = self.db.jobs self.reports = self.db.reports def create_job(self, job_id: str, inputs: Dict[str, Any]) -> None: """Create a new research job record.""" self.jobs.insert_one({ "job_id": job_id, "inputs": inputs, "status": "pending", "created_at": datetime.utcnow(), "updated_at": datetime.utcnow() }) def update_job(self, job_id: str, status: str = None, result: Dict[str, Any] = None, error: str = None) -> None: """Update a research job with results or status.""" update_data = {"updated_at": datetime.utcnow()} if status: update_data["status"] = status if result: update_data["result"] = result if error: update_data["error"] = error self.jobs.update_one( {"job_id": job_id}, {"$set": update_data} ) def get_job(self, job_id: str) -> Optional[Dict[str, Any]]: """Retrieve a job by ID.""" return self.jobs.find_one({"job_id": job_id}) def store_report(self, job_id: str, report_data: Dict[str, Any]) -> None: """Store the finalized research report.""" self.reports.insert_one({ "job_id": job_id, "report_content": report_data.get("report", ""), "references": report_data.get("references", []), "sections": report_data.get("sections_completed", []), "analyst_queries": report_data.get("analyst_queries", {}), "created_at": datetime.utcnow() }) def get_report(self, job_id: str) -> Optional[Dict[str, Any]]: """Retrieve a report by job ID.""" return self.reports.find_one({"job_id": job_id})company-research-agent/backend/services/pdf_service.pycode · 2.4 KB
import io import logging import os import re from backend.utils.utils import generate_pdf_from_md logger = logging.getLogger(__name__) class PDFService: def __init__(self, config): self.output_dir = config.get("pdf_output_dir", "pdfs") # Create output directory if it doesn't exist os.makedirs(self.output_dir, exist_ok=True) def _sanitize_company_name(self, company_name): """Sanitize company name for use in filenames.""" # Replace spaces with underscores and remove special characters sanitized = re.sub(r'[^\w\s-]', '', company_name).strip().replace(' ', '_') return sanitized.lower() def _generate_pdf_filename(self, company_name): """Generate a PDF filename based on the company name.""" sanitized_name = self._sanitize_company_name(company_name) return f"{sanitized_name}_report.pdf" def generate_pdf_stream(self, markdown_content, company_name=None): """ Generate a PDF from markdown content and return it as a stream. Args: markdown_content (str): The markdown content to convert to PDF company_name (str, optional): The company name to use in the filename Returns: tuple: (success status, PDF stream or error message) """ try: # Extract company name from the first line if not provided if not company_name: first_line = markdown_content.split('\n')[0].strip() if first_line.startswith('# '): company_name = first_line[2:].strip() else: company_name = "Company Research" # Generate the output filename pdf_filename = self._generate_pdf_filename(company_name) # Create a BytesIO object to store the PDF pdf_buffer = io.BytesIO() # Generate the PDF directly to the buffer generate_pdf_from_md(markdown_content, pdf_buffer) # Reset buffer position to start pdf_buffer.seek(0) # Return success and the buffer return True, (pdf_buffer, pdf_filename) except Exception as e: error_msg = f"Error generating PDF: {str(e)}" logger.error(error_msg) return False, error_msgcompany-research-agent/backend/utils/__init__.pycode · 0.3 KB
from .utils import generate_pdf_from_md, clean_text from .references import ( extract_domain_name, extract_title_from_url_path, clean_title, normalize_url, extract_website_name_from_domain, process_references_from_search_results, format_reference_for_markdown, extract_link_info, format_references_section )company-research-agent/backend/utils/references.pycode · 14.2 KB · preview truncated
import logging import re from typing import Any, Dict, List, Tuple from urllib.parse import urlparse logger = logging.getLogger(__name__) def extract_domain_name(url: str) -> str: """Extract a readable website name from a URL.""" try: # Remove protocol and www domain = url.lower() for prefix in ['https://', 'http://', 'www.']: if domain.startswith(prefix): domain = domain[len(prefix):] # Get the main domain part (before first slash or query) domain = domain.split('/')[0].split('?')[0] # Extract the main part (e.g., 'tavily' from 'tavily.com') parts = domain.split('.') if len(parts) >= 2: main_name = parts[0] # Capitalize the name return main_name.capitalize() return domain.capitalize() except Exception as e: logger.error(f"Error extracting domain name from {url}: {e}") return "Website" def extract_title_from_url_path(url: str) -> str: """Extract a meaningful title from the URL path.""" try: # Remove protocol, www, and domain path = url.lower() for prefix in ['https://', 'http://', 'www.']: if path.startswith(prefix): path = path[len(prefix):] # Remove domain if '/' in path: path = path.split('/', 1)[1] else: path = "" # Clean up the path to create a title if path: # Remove file extensions and query parameters path = path.split('?')[0].split('#')[0] if path.endswith('/'): path = path[:-1] # Replace hyphens and underscores with spaces path = path.replace('-', ' ').replace('_', ' ').replace('/', ' - ') # Capitalize words title = ' '.join(word.capitalize() for word in path.split()) # If title is still too long, truncate it if len(title) > 100: title = title[:97] + "..." return title return "" except Exception as e: logger.error(f"Error extracting title from URL path: {e}") return "" def clean_title(title: str) -> str: """Clean up a title by removing dates, trailing periods or quotes, and truncating if needed.""" if not title: return "" original_title = title title = title.strip().rstrip('.').strip('"\'') title = re.sub(r'^\d{4}[-\s]*\d{1,2}[-\s]*\d{1,2}[-\s]*', '', title) title = title.strip('- ').strip() # If title became empty after cleaning, return empty string if not title: logger.warning(f"Title became empty after cleaning: '{original_title}'") return "" # Log if we made changes to the title if title != original_title: logger.info(f"Cleaned title from '{original_title}' to '{title}'") return title def normalize_url(url: str) -> str: """Normalize a URL by removing query parameters and fragments.""" try: if not url: return "" # Ensure URL has a scheme if not url.startswith(('http://', 'https://')): url = 'https://' + url parsed = urlparse(url) normalized_url = parsed._replace(query='', fragment='').geturl().rstrip('/') return normalized_url except Exception as e: logger.error(f"Error normalizing URL {url}: {e}") return url def extract_website_name_from_domain(domain: str) -> str: """Extract a readable website name from a domain.""" if domain.startswith('www.'): domain = domain[4:] # Remove www. prefix # Extract the main part (e.g., 'tavily' from 'tavily.com') website_name = domain.split('.')[0].capitalize() # Handle special cases if website_name == "Com": # Try to get a better name from the second part parts = domain.split('.') if len(parts) > 1: website_name = parts[0].capitalize() return website_name def process_references_from_search_results(state: Dict[str, Any]) -> Tuple[List[str], Dict[str, str], Dict[str, Dict[str, Any]]]: """Process references from search results and return top references, titles, and info.""" all_top_references = [] # Collect references with scores from all data types data_types = ['curated_company_data', 'curated_industry_data', 'curated_financial_data', 'curated_news_data'] # Log the start of reference processing logger.info("Starting to process references from search results") for data_type in data_types: if curated_data := state.get(data_type, {}): for url, doc in curated_data.items(): try: # Ensure we have a valid score if 'evaluation' in doc and 'overall_score' in doc['evaluation']: score = float(doc['evaluation']['overall_score']) else: # Fallback to raw score if available score = float(doc.get('score', 0)) logger.info(f"Found reference in {data_type}: URL={url}, Score={score:.4f}") all_top_references.append((url, score)) except (KeyError, ValueError, TypeError) as e: logger.warning(f"Error processing score for {url} in {data_type}: {e}") continue logger.info(f"Collected a total of {len(all_top_references)} references before deduplication") # Sort references by score in descending order all_top_references.sort(key=lambda x: float(x[1]), reverse=True) # Log top 20 references before deduplication to verify sorting logger.info("Top 20 references by score before deduplication:") for i, (url, score) in enumerate(all_top_references[:20]): logger.info(f"{i+1}. Score: {score:.4f} - URL: {url}") # Use a set to store unique URLs, keeping only the highest scored version of each URL seen_urls = set() unique_references = [] reference_titles = {} # Store titles for references reference_info = {} # Store additional information for MLA-style references for url, score in all_top_references: # Skip if URL is not valid if not url or not url.startswith(('http://', 'https://')): logger.info(f"Skipping invalid URL: {url}") continue # Normalize URL normalized_url = normalize_url(url) if normalized_url not in seen_urls: seen_urls.add(normalized_url) unique_references.append((normalized_url, score)) # Extract domain name for website citation parsed = urlparse(url) domain = parsed.netloc # Find and store the title and other info for this URL title = None website_name = None # Look for the document info in all data types for data_type in data_types: if not title and (curated_data := state.get(data_type, {})): for doc in curated_data.values(): if doc.get('url') == url: title = doc.get('title', '') if title: # Clean up the title title = clean_title(title) if title and title.strip() and title != url: reference_titles[normalized_url] = title logger.info(f"Found title for URL {url}: '{title}'") break # If no title was found, log it if not title: logger.info(f"No valid title found for URL {url}") # Extract a better website name from the domain website_name = extract_website_name_from_domain(domain) # Store additional information for MLA citation reference_info[normalized_url] = { 'title': title or '', 'domain': domain, 'website': website_name, 'url': normalized_url, 'score': score } logger.info(f"Stored reference info for {normalized_url} with score {score:.4f}") # Sort unique references by score again to ensure proper ordering unique_references.sort(key=lambda x: float(x[1]), reverse=True) # Log unique references by score to verify sorting logger.info(f"Found {len(unique_references)} unique references after deduplication") logger.info("Unique references by score (sorted):") for i, (url, score) in enumerate(unique_references): logger.info(f"{i+1}. Score: {score:.4f} - URL: {url}") # Take exactly 10 unique references (or all if less than 10) top_references = unique_references[:10] top_reference_urls = [url for url, _ in top_references] # Log final top 10 references logger.info(f"Final top {len(top_reference_urls)} references selected:") for i, url in enumerate(top_reference_urls): score = next((s for u, s in unique_references if u == url), 0) logger.info(f"{i+1}. Score: {score:.4f} - URL: {url}") return top_reference_urls, reference_titles, reference_info def format_reference_for_markdown(reference_entry: Dict[str, Any]) -> str: """Format a reference entry for markdown output.""" website = reference_entry.get('website', '') title = reference_entry.get('title', '') url = reference_entry.get('url', '') # Ensure we have a website name if not website or website.strip() == "": website = extract_domain_name(url) # Ensure we have a title if not title or title.strip() == "" or title == url: # Try to extract a meaningful title from the URL title = extract_title_from_url_path(url) # If still no title, use default format if not title: title = f"Information from {website}" # Format: * Website. "Title." URL return f"* {website}. \"{title}.\" {url}" def extract_link_info(line: str) -> tuple[str, str]: """Extract title and URL from markdown link.""" try: # First clean any JSON artifacts that might interfere with link parsing line = re.sub(r'",?\s*"pdf_url":.+$', '', line) # Check for MLA-style references with website and title before the link # Format: * Website. "Title." [URL](URL) mla_match = re.match(r'\*?\s*(.*?)\s*\.\s*"(.*?)\."\s*\[(.*?)\]\((.*?)\)', line) if mla_match: website = clean_title(mla_match.group(1)) title = clean_title(mla_match.group(2)) link_text = clean_title(mla_match.group(3)) url = clean_title(mla_match.group(4)) # If website is empty or just a period, extract from URL if not website or website == ".": website = extract_domain_name(url) # Format for PDF: "Website. Title. URL" return f"{website}. {title}. {link_text}", url # Fallback for standard markdown links match = re.match(r'\[(.*?)\]\((.*?)\)', line) if match: title = clean_title(match.group(1)) url = clean_title(match.group(2)) # If the title is a URL and matches the URL, just use the URL if title.startswith('http') and title == url: return url, url return title, url logger.debug(f"No link match found in line: {line}") return '', '' except Exception as e: logger.error(f"Error extracting link infocompany-research-agent/backend/utils/utils.pycode · 16.2 KB · preview truncated
import logging import os import re from typing import Dict, List from reportlab.lib import colors from reportlab.lib.pagesizes import letter from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.platypus import ( ListFlowable, ListItem, Paragraph, SimpleDocTemplate, Spacer, ) from .references import extract_link_info logger = logging.getLogger(__name__) def clean_text(text: str) -> str: """Clean up text by replacing escaped quotes and other special characters.""" text = re.sub(r'",?\s*"pdf_url":.+$', '', text) text = text.replace('\\"', '"') text = text.replace('\\n', '\n') text = text.replace('<para>', '').replace('</para>', '') return text.strip() def generate_pdf_from_md(markdown_content: str, output_pdf) -> None: """Convert markdown content to PDF using a simplified ReportLab approach. Args: markdown_content (str): The markdown content to convert to PDF output_pdf: Either a file path string or a BytesIO object """ try: # If output_pdf is a string (file path), ensure directory exists if isinstance(output_pdf, str): os.makedirs(os.path.dirname(os.path.abspath(output_pdf)), exist_ok=True) markdown_content = markdown_content.replace('\r\n', '\n') # Normalize Windows line endings markdown_content = markdown_content.replace('\\n', '\n') # Convert literal \n to newlines # Create the PDF document doc = SimpleDocTemplate( output_pdf, pagesize=letter, rightMargin=40, leftMargin=40, topMargin=40, bottomMargin=40 ) # Setup styles styles = getSampleStyleSheet() # Custom styles title_style = ParagraphStyle( 'Title', parent=styles['Heading1'], fontSize=20, textColor=colors.black, spaceAfter=12 ) heading2_style = ParagraphStyle( 'Heading2', parent=styles['Heading2'], fontSize=16, textColor=colors.black, spaceBefore=12, spaceAfter=6, fontName='Helvetica-Bold' ) heading3_style = ParagraphStyle( 'Heading3', parent=styles['Heading3'], fontSize=12, textColor=colors.black, spaceBefore=10, spaceAfter=4 ) normal_style = ParagraphStyle( 'Normal', parent=styles['Normal'], fontSize=10, textColor=colors.black, spaceBefore=2, spaceAfter=2 ) list_item_style = ParagraphStyle( 'ListItem', parent=styles['Normal'], fontSize=10, textColor=colors.black, spaceBefore=2, spaceAfter=2, leftIndent=10, firstLineIndent=0, bulletIndent=0 ) # Create the story (content) story = [] # Process markdown content into PDF elements lines = markdown_content.split('\n') i = 0 # Track if we're in a list in_list = False list_items = [] while i < len(lines): line = lines[i].strip() # Skip empty lines if not line: if in_list and list_items: # Flush list if we were building one story.append(ListFlowable( [ListItem(Paragraph(item, list_item_style)) for item in list_items], bulletType='bullet', leftIndent=10, bulletFontName='Helvetica', bulletFontSize=10, bulletOffsetY=0, bulletDedent=10, spaceAfter=0 )) list_items = [] in_list = False story.append(Spacer(1, 6)) i += 1 continue # Headings if line.startswith('# '): story.append(Paragraph(line[2:], title_style)) elif line.startswith('## '): story.append(Paragraph(line[3:], heading2_style)) elif line.startswith('### '): story.append(Paragraph(line[4:], heading3_style)) # Bullet points elif line.startswith('* '): bullet_text = line[2:].strip() # Remove the '* ' but keep any other asterisks # For links in bullet points if bullet_text.startswith('[') and '](' in bullet_text and bullet_text.endswith(')'): link_text, link_url = extract_link_info(bullet_text) # Simplified link format to avoid potential formatting issues bullet_text = f'<link href="{link_url}" color="blue"><u>{link_text or link_url}</u></link>' list_items.append(bullet_text) in_list = True # Regular paragraphs (including links) else: # Handle bold and italic text line = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', line) # Bold line = re.sub(r'\*(.*?)\*', r'<i>\1</i>', line) # Italic # Check for links in the text if '[' in line and '](' in line: try: # Process links parts = [] last_idx = 0 for match in re.finditer(r'\[(.*?)\]\((.*?)\)', line): # Add text before the link if match.start() > last_idx: parts.append(line[last_idx:match.start()]) # Add the link link_text = match.group(1) link_url = match.group(2) parts.append(f'<link href="{link_url}" color="blue"><u>{link_text}</u></link>') last_idx = match.end() # Add any remaining text if last_idx < len(line): parts.append(line[last_idx:]) line = ''.join(parts) except Exception as e: # If link processing fails, use the original line logger.error(f"Error processing links: {e}") # Add the paragraph story.append(Paragraph(line, normal_style)) i += 1 # Flush any remaining list if in_list and list_items: story.append(ListFlowable( [ListItem(Paragraph(item, list_item_style)) for item in list_items], bulletType='bullet', leftIndent=10, bulletFontName='Helvetica', bulletFontSize=10, bulletOffsetY=0, bulletDedent=10, spaceAfter=0 )) # Build the PDF doc.build(story) logger.info(f"Successfully generated PDF: {output_pdf}") except Exception as e: error_msg = f"Error generating PDF: {str(e)}" logger.error(error_msg) raise Exception(error_msg) def convert_markdown_to_pdf_elements(markdown_text: str, custom_styles: Dict) -> List: """ Example function that converts a Markdown string into a list of ReportLab Flowable elements. This is separate from generate_pdf_from_md. """ story = [] current_list_items = [] in_list = False lines = markdown_text.split('\n') i = 0 def process_markdown_formatting(text): # Bold text = re.sub(r'(?<!\*)\*\*(.*?)\*\*(?!\*)', r'<b>\1</b>', text) # Clean up any remaining double asterisks (bold markers) but preserve single asterisks for bullet points text = text.replace('**', '') return text while i < len(lines): line = lines[i].strip() # Blank line if not line: if in_list and current_list_items: story.append(ListFlowable( [ ListItem( Paragraph(item, custom_styles['ListItem']), value='bullet', leftIndent=20, bulletColor=colors.HexColor('#2c3e50'), bulletType='bullet', bulletFontName='Helvetica', bulletFontSize=10 ) for item in current_list_items ], bulletType='bullet', leftIndent=20, bulletOffsetX=10, bulletOffsetY=2, start=None, bulletDedent=20, bulletFormat='•', spaceBefore=4, spaceAfter=4 )) current_list_items = [] in_list = False story.append(Spacer(1, 6)) i += 1 continue # Headings if line.startswith('#'): if in_list and current_list_items: # Flush the list story.append(ListFlowable( [ ListItem( Paragraph(item, custom_styles['ListItem']), value='bullet', leftIndent=20, bulletColor=colors.HexColor('#2c3e50'), bulletType='bullet', bulletFontName='Helvetica', bulletFontSize=10 ) for item in current_list_items ], bulletType='bullet', leftIndent=20, bulletOffsetX=10, bulletOffsetY=2, start=None, bulletDedent=20, bulletFormat='•', spaceBefore=4, spaceAfter=4 )) current_list_items = [] in_list = False heading_level = len(line.split()[0]) # number of '#' characters heading_text = ' '.join(line.split()[1:]) style_name = f'Heading{heading_level}' # Use an existing style or a custom style story.append(Paragraph(heading_text, custom_styles.get(style_name, custom_styles['BodyText']))) i += 1 continue # Bullets if line.startswith('* '): bullet_text = line[2:].strip() # Remove the '* ' but keep any other asterisks # For non-references bullet points if bullet_text.startswith('[') and '](' in bullet_text and bullet_text.endswith(')'): # It's a link bullet title, url = extract_link_info(bullet_text) bullet_text = f'<link href="{url}" color="blue" textColor="blue"><u>{title or url}</u></link>' else: # Only process non-link text bullet_text = process_markdown_formatting(bullet_text) # Add it immediately as a single bullet item with explicit bullet styling story.append(ListFlowable( [ ListItem( Paragcompany-research-agent/docker-compose.ymlcode · 0.6 KB
services: backend: build: context: . dockerfile: Dockerfile ports: - "8000:8000" environment: - PYTHONUNBUFFERED=1 - PORT=8000 volumes: - ./reports:/app/reports - ./backend:/app/backend - ./application.py:/app/application.py env_file: - .env frontend: image: node:20-slim working_dir: /ui command: sh -c "npm install && npm run dev" ports: - "5174:5174" volumes: - ./ui:/ui environment: - NODE_ENV=development - VITE_API_URL=http://localhost:8000company-research-agent/langgraph.jsoncode · 0.2 KB
No inline preview for this entry.
company-research-agent/langgraph_entry.pycode · 0.1 KB
No inline preview for this entry.
company-research-agent/package-lock.jsoncode · 0.1 KB
No inline preview for this entry.
company-research-agent/requirements.txtcode · 0.2 KB
No inline preview for this entry.
company-research-agent/setup.shcode · 9.0 KB
No inline preview for this entry.
company-research-agent/static/agent-flow.pngbinary · 102.3 KB
Binary — download to inspect.
company-research-agent/static/app-screenshot.pngbinary · 1189.5 KB
Binary — download to inspect.
company-research-agent/static/ui-1.pngbinary · 636.0 KB
Binary — download to inspect.
company-research-agent/static/ui-2.pngbinary · 584.2 KB
Binary — download to inspect.
company-research-agent/ui/.env.development.exampletext · 0.1 KB
No inline preview for this entry.
company-research-agent/ui/.gitignoretext · 0.1 KB
No inline preview for this entry.
company-research-agent/ui/eslint.config.jscode · 0.7 KB
No inline preview for this entry.
company-research-agent/ui/index.htmlcode · 0.5 KB
No inline preview for this entry.
company-research-agent/ui/package-lock.jsontext · 200.3 KB
No inline preview for this entry.
company-research-agent/ui/package.jsoncode · 1.1 KB
No inline preview for this entry.
company-research-agent/ui/postcss.config.jscode · 0.1 KB
No inline preview for this entry.
company-research-agent/ui/public/company-logos/adobe.pngbinary · 0.6 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/airbnb.pngbinary · 1.1 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/amazon.pngbinary · 0.6 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/apple.pngbinary · 2.4 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/atlassian.pngbinary · 1.0 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/canva.pngbinary · 1.1 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/coinbase.pngbinary · 1.0 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/datadog.pngbinary · 1.3 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/figma.pngbinary · 0.7 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/google.pngbinary · 1.0 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/meta.pngbinary · 1.2 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/microsoft.pngbinary · 0.2 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/netflix.pngbinary · 1.4 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/notion.pngbinary · 1.5 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/nvidia.pngbinary · 1.6 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/salesforce.pngbinary · 0.6 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/shopify.pngbinary · 1.4 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/slack.pngbinary · 0.9 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/snowflake.pngbinary · 0.6 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/spotify.pngbinary · 2.4 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/stripe.pngbinary · 0.4 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/tesla.pngbinary · 0.7 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/uber.pngbinary · 0.7 KB
Binary — download to inspect.
company-research-agent/ui/public/company-logos/zoom.pngbinary · 1.5 KB
Binary — download to inspect.
company-research-agent/ui/public/favicon.icobinary · 2.6 KB
Binary — download to inspect.
company-research-agent/ui/public/fonts/SuisseIntl-Medium.otfbinary · 126.2 KB
Binary — download to inspect.
company-research-agent/ui/public/fonts/SuisseIntl-Regular.otfbinary · 127.1 KB
Binary — download to inspect.
company-research-agent/ui/public/github-icon.pngbinary · 3.5 KB
Binary — download to inspect.
company-research-agent/ui/public/model-logos/ai21.svgtext · 1.2 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/amazon.svgtext · 2.9 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/anthropic.svgtext · 1.7 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/bytedance.svgtext · 0.9 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/cohere.svgtext · 0.8 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/deepseek.svgtext · 2.1 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/google.svgtext · 2.8 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/ibm.svgtext · 1.6 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/liquid.svgtext · 0.5 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/meta.svgtext · 5.9 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/microsoft.svgtext · 0.4 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/minimax.svgtext · 1.5 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/mistralai.svgtext · 0.6 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/moonshotai.svgtext · 0.7 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/nousresearch.svgtext · 19.3 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/nvidia.svgtext · 1.0 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/openai.svgtext · 1.7 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/openrouter.svgtext · 0.9 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/perplexity.svgtext · 0.6 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/qwen.svgtext · 2.0 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/stepfun.svgtext · 0.7 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/tencent.svgtext · 1.1 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/upstage.svgtext · 0.9 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/x-ai.svgtext · 0.7 KB
No inline preview for this entry.
company-research-agent/ui/public/model-logos/z-ai.svgtext · 0.3 KB
No inline preview for this entry.
company-research-agent/ui/public/tavily-brand-mark.svgtext · 2.2 KB
No inline preview for this entry.
company-research-agent/ui/public/tavily-by-nebius.svgtext · 6.8 KB
No inline preview for this entry.
company-research-agent/ui/public/tavily-full.svgtext · 3.9 KB
No inline preview for this entry.
company-research-agent/ui/public/tavily-landscape.webpbinary · 360.6 KB
Binary — download to inspect.
company-research-agent/ui/public/tavilylogo.pngbinary · 16.9 KB
Binary — download to inspect.
company-research-agent/ui/src/App.tsxcode · 22.4 KB
No inline preview for this entry.
company-research-agent/ui/src/components/CurationExtraction.tsxcode · 3.1 KB
No inline preview for this entry.
company-research-agent/ui/src/components/ExamplePopup.tsxcode · 3.4 KB
No inline preview for this entry.
company-research-agent/ui/src/components/Header.tsxcode · 2.0 KB
No inline preview for this entry.
company-research-agent/ui/src/components/LocationInput.tsxcode · 8.3 KB
No inline preview for this entry.
company-research-agent/ui/src/components/ModelLogo.tsxcode · 0.6 KB
No inline preview for this entry.
company-research-agent/ui/src/components/ModelSelector.tsxcode · 7.3 KB
No inline preview for this entry.
company-research-agent/ui/src/components/ResearchBriefings.tsxcode · 3.5 KB
No inline preview for this entry.
company-research-agent/ui/src/components/ResearchForm.tsxcode · 13.5 KB
No inline preview for this entry.
company-research-agent/ui/src/components/ResearchQueries.tsxcode · 3.5 KB
No inline preview for this entry.
company-research-agent/ui/src/components/ResearchReport.tsxcode · 7.2 KB
No inline preview for this entry.
company-research-agent/ui/src/components/ResearchStatus.tsxcode · 2.3 KB
No inline preview for this entry.
company-research-agent/ui/src/components/SettingsPanel.tsxcode · 7.1 KB
No inline preview for this entry.
company-research-agent/ui/src/components/index.tscode · 0.6 KB
No inline preview for this entry.
company-research-agent/ui/src/env.d.tscode · 0.3 KB
No inline preview for this entry.
company-research-agent/ui/src/hooks/useSmooth.tscode · 3.1 KB
No inline preview for this entry.
company-research-agent/ui/src/index.csscode · 22.5 KB
No inline preview for this entry.
company-research-agent/ui/src/main.tsxcode · 0.2 KB
No inline preview for this entry.
company-research-agent/ui/src/models.tscode · 2.9 KB
No inline preview for this entry.
company-research-agent/ui/src/settings.tscode · 1.1 KB
No inline preview for this entry.
company-research-agent/ui/src/styles/index.tscode · 0.3 KB
No inline preview for this entry.
company-research-agent/ui/src/types/index.tscode · 1.2 KB
No inline preview for this entry.
company-research-agent/ui/src/vite-env.d.tscode · 0.0 KB
No inline preview for this entry.
company-research-agent/ui/tailwind.config.jscode · 0.5 KB
No inline preview for this entry.
company-research-agent/ui/tsconfig.app.jsoncode · 0.5 KB
No inline preview for this entry.
company-research-agent/ui/tsconfig.jsoncode · 0.1 KB
No inline preview for this entry.
company-research-agent/ui/tsconfig.node.jsoncode · 0.2 KB
No inline preview for this entry.
company-research-agent/ui/vercel.jsoncode · 0.1 KB
No inline preview for this entry.
company-research-agent/ui/vite.config.tscode · 0.6 KB
No inline preview for this entry.