TalentDelta

Our site is fully accessible by humans and agents (via WebMCP).

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

Provenance

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).

Our review

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
    *.log 
  • company-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:

    1. Go to '...'
    2. Click on '....'
    3. Scroll down to '....'
    4. 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
    .ebextensions
  • company-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

    en zh fr es jp kr

    Investigador de Empresas 🔍

    interfaz web

    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:

    1. Nodos de Investigación:

      • CompanyAnalyzer: Investiga información básica del negocio
      • IndustryAnalyzer: Analiza posición de mercado y tendencias
      • FinancialAnalyst: Recopila métricas financieras y datos de rendimiento
      • NewsScanner: Recopila noticias y desarrollos recientes
    2. Nodos de Procesamiento:

      • Collector: Agrega datos de investigación de todos los analizadores
      • Curator: Implementa filtrado de contenido y puntuación de relevancia
      • Briefing: Genera resúmenes específicos por categoría utilizando el modelo seleccionado
      • Editor: Compila y formatea los resúmenes en un informe final utilizando el modelo seleccionado

      interfaz web

    Arquitectura de Generación de Contenido

    Todos los pasos con LLM (generación de consultas en los investigadores, Briefing y Editor) utilizan un único modelo. El proveedor se elige en backend/llm.py:

    1. OpenRouter (recomendado):

      • Con una clave de OpenRouter (OPENROUTER_API_KEY en el .env del 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 con OPENROUTER_MODEL
    2. Claves directas (alternativa):

      • Sin clave de OpenRouter, el backend utiliza OPENAI_API_KEY (modelos GPT)
      • Si GEMINI_API_KEY está configurada, se usa Gemini 2.5 Flash solo para los resúmenes; la clave de Gemini ya no es obligatoria

    Sistema de Selección de Contenido

    La plataforma utiliza un sistema de filtrado de contenido en curator.py:

    1. 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
    2. 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:

    interfaz web

    1. 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
    2. 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
    3. Endpoints de la API:

      • POST /research: Enviar nueva solicitud de investigación (acepta opcionalmente openrouter_api_key y model)
      • 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 predeterminado
      • GET /research/{job_id}/report: Polling para informe completado
      • POST /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 uv para una instalación más rápida de paquetes Python cuando está disponible:

    1. Clonar el repositorio:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. Hacer que el script de instalación sea ejecutable y ejecutarlo:
    chmod +x setup.sh
    ./setup.sh
    

    El script de instalación hará lo siguiente:

    • Detectar y usar uv para 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 | sh
    

    Necesitará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:

    1. Clonar el repositorio:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. 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
    
    1. Instalar dependencias de frontend:
    cd ui
    npm install
    
    1. Configurar Variables de Entorno:

    Este proyecto requiere dos archivos .env separados para el backend y el frontend.

    Configuración del Backend:

    Crea un archivo .env en 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_mongodb
    

    Configuración del Frontend:

    Crea un archivo .env dentro del directorio ui. Puedes copiar primero el archivo de ejemplo:

    cp ui/.env.development.example ui/.env
    

    Luego, abre ui/.env y añade tus variables de entorno del frontend:

    VITE_API_URL=http://localhost:8000
    VITE_GOOGLE_MAPS_API_KEY=tu_clave_google_maps_aqui
    

    Instalación con Docker

    La aplicación puede ejecutarse utilizando Docker y Docker Compose:

    1. Clonar el repositorio:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. Configurar Variables de Entorno:

    La configuración de Docker utiliza dos archivos .env separados.

    Configuración del Backend:

    Crea un archivo .env en 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_mongodb
    

    Configuración del Frontend:

    Crea un archivo .env dentro del directorio ui. Puedes copiar primero el archivo de ejemplo:

    cp ui/.env.development.example ui/.env
    

    Luego, abre ui/.env y añade tus variables de entorno del frontend:

    VITE_API_URL=http://localhost:8000
    VITE_GOOGLE_MAPS_API_KEY=tu_clave_google_maps_aqui
    
    1. Construir e iniciar los contenedores:
    docker compose up --build
    

    Esto 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 down
    

    Nota: Al actualizar las variables de entorno en .env, necesitarás reiniciar los contenedores:

    docker compose down && docker compose up
    

    Elegir 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 si GEMINI_API_KEY está configurada).

    Ejecutando la Aplicación

    1. 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
    
    1. En una nueva terminal, iniciar el frontend:
    cd ui
    npm run dev
    
    1. Acceder a la aplicación en http://localhost:5173

    Uso

    Desarrollo Local

    1. 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

      # 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 8000
      

      El backend estará disponible en:

      • Punto de conexión API: http://localhost:8000
    2. Iniciar el servidor de desarrollo del frontend:

      cd ui
      npm run dev
      
    3. Acceder a la aplicación en http://localhost:5173

    ⚡ Nota de Rendimiento: Si usaste uv durante la instalación, te beneficiarás de una instalación de paquetes y resolución de dependencias significativamente más rápida. uv es 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

    1. Haz un fork del repositorio
    2. Crea una rama de características (git checkout -b feature/caracteristica-increible)
    3. Haz commit de tus cambios (git commit -m 'Añadir característica increíble')
    4. Haz push a la rama (git push origin feature/caracteristica-increible)
    5. 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

    en zh fr es jp kr

    Agent de Recherche d'Entreprise 🔍

    web ui

    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 :

    1. Nœuds de Recherche :

      • CompanyAnalyzer : Recherche les informations principales sur l'entreprise
      • IndustryAnalyzer : Analyse la position sur le marché et les tendances
      • FinancialAnalyst : Récupère les indicateurs financiers et les données de performance
      • NewsScanner : Collecte les actualités et développements récents
    2. Nœuds de Traitement :

      • Collector : Agrège les données de recherche de tous les analyseurs
      • Curator : Met en œuvre le filtrage de contenu et le scoring de pertinence
      • Briefing : 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é

      web ui

    Architecture de Génération de Contenu

    Toutes les étapes LLM (génération des requêtes par les chercheurs, Briefing et Editor) utilisent un seul et même modèle. Le fournisseur est choisi dans backend/llm.py :

    1. OpenRouter (recommandé) :

      • Activé lorsqu'une clé OpenRouter est disponible (OPENROUTER_API_KEY dans le .env du 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 via OPENROUTER_MODEL
    2. 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_KEY est défini ; une clé Gemini n'est plus obligatoire

    Système de Curation de Contenu

    La plateforme utilise un système de filtrage de contenu dans curator.py :

    1. 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
    2. 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 :

    web ui

    1. 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
    2. 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é
    3. Endpoints de l'API :

      • POST /research : Soumettre une nouvelle demande de recherche (accepte en option openrouter_api_key et model)
      • GET /research/{job_id}/report : Polling pour le rapport terminé
      • POST /generate-pdf : Générer un PDF du contenu du rapport
      • GET /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 uv pour une installation plus rapide des paquets Python lorsqu'il est disponible :

    1. Clonez le dépôt :
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. Rendez le script de configuration exécutable et lancez-le :
    chmod +x setup.sh
    ./setup.sh
    

    Le script de configuration va :

    • Détecter et utiliser uv pour 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 | sh
    

    Vous 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 :

    1. Clonez le dépôt :
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. 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
    
    1. Installez les dépendances frontend :
    cd ui
    npm install
    
    1. Configuration des Variables d'Environnement :

    Ce projet nécessite deux fichiers .env séparés pour le backend et le frontend.

    Configuration Backend :

    Créez un fichier .env dans 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_mongodb
    

    Configuration Frontend :

    Créez un fichier .env dans le répertoire ui. Vous pouvez d'abord copier le fichier d'exemple :

    cp ui/.env.development.example ui/.env
    

    Puis, ouvrez ui/.env et ajoutez vos variables d'environnement frontend :

    VITE_API_URL=http://localhost:8000
    VITE_GOOGLE_MAPS_API_KEY=votre_clé_google_maps_ici
    

    Configuration Docker

    L'application peut être exécutée à l'aide de Docker et Docker Compose :

    1. Clonez le dépôt :
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. Configuration des Variables d'Environnement :

    La configuration Docker utilise deux fichiers .env séparés.

    Configuration Backend :

    Créez un fichier .env dans 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_mongodb
    

    Configuration Frontend :

    Créez un fichier .env dans le répertoire ui. Vous pouvez d'abord copier le fichier d'exemple :

    cp ui/.env.development.example ui/.env
    

    Puis, ouvrez ui/.env et ajoutez vos variables d'environnement frontend :

    VITE_API_URL=http://localhost:8000
    VITE_GOOGLE_MAPS_API_KEY=votre_clé_google_maps_ici
    
    1. Construisez et démarrez les conteneurs :
    docker compose up --build
    

    Cela 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 down
    

    Remarque : Lors de la mise à jour des variables d'environnement dans .env, vous devrez redémarrer les conteneurs :

    docker compose down && docker compose up
    

    Exécution de l'Application

    1. 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
    
    1. Dans un nouveau terminal, démarrez le frontend :
    cd ui
    npm run dev
    
    1. 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 si GEMINI_API_KEY est défini).

    Développement Local

    1. Démarrez le serveur backend (choisissez une option) :

      Option 1 : Module Python Direct

      python -m application.py
      

      Option 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 8000
      

      Le backend sera disponible sur :

      • Point d'accès API : http://localhost:8000
    2. Démarrez le serveur de développement frontend :

      cd ui
      npm run dev
      
    3. Accédez à l'application sur http://localhost:5173

    ⚡ Note de Performance : Si vous avez utilisé uv lors de l'installation, vous bénéficierez d'une installation de paquets et d'une résolution de dépendances significativement plus rapides. uv est 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

    en zh fr es jp kr

    企業調査エージェント 🔍

    web ui

    包括的な企業調査レポートを生成するマルチエージェントツール。このプラットフォームは、AIエージェントのパイプラインを使用して、あらゆる企業に関する情報を収集、整理、統合します。

    ✨オンラインで試してみてください! https://companyresearcher.tavily.com ✨

    https://github.com/user-attachments/assets/0e373146-26a7-4391-b973-224ded3182a9

    機能

    • マルチソース調査:企業ウェブサイト、ニュース記事、財務報告書、業界分析など、様々なソースからデータを取得
    • AIによるコンテンツフィルタリング:Tavilyの関連性スコアを使用したコンテンツキュレーション
    • 非同期処理:調査の進捗を追跡するための効率的なポーリングベースアーキテクチャ
    • 柔軟なモデル選択:設定パネルで選んだOpenRouterの任意のモデルを使用可能。フォールバックとしてOpenAI/Geminiの直接キーにも対応
    • モダンなReactフロントエンド:進捗追跡とダウンロードオプションを備えたレスポンシブインターフェース
    • モジュラーアーキテクチャ:専門的な調査・処理ノードのパイプラインを中心に構築

    エージェントフレームワーク

    調査パイプライン

    このプラットフォームは、データを順次処理する専門ノードを持つエージェントフレームワークに従います:

    1. 調査ノード:

      • CompanyAnalyzer:主要な企業情報を調査
      • IndustryAnalyzer:市場ポジションとトレンドを分析
      • FinancialAnalyst:財務指標とパフォーマンスデータを取得
      • NewsScanner:最新のニュースと動向を収集
    2. 処理ノード:

      • Collector:すべてのアナライザーから調査データを集約
      • Curator:コンテンツフィルタリングと関連性スコアリングを実装
      • Briefing:選択したモデルを使用してカテゴリ別の要約を生成
      • Editor:選択したモデルで要約を最終レポートにコンパイル・書式設定

      web ui

    コンテンツ生成アーキテクチャ

    すべてのLLMステップ(調査ノードでのクエリ生成、Briefing、Editor)は1つのモデルを使用します。プロバイダーの選択はbackend/llm.pyで行われます:

    1. OpenRouter(推奨):

      • OpenRouterキー(バックエンド.envのOPENROUTER_API_KEY、またはアプリの設定パネルで貼り付けたキー)がある場合、すべてのステップで設定パネルで選択したOpenRouterモデルを使用
      • デフォルトモデルはgoogle/gemini-3.8-flashで、OPENROUTER_MODELで変更可能
    2. フォールバック:

      • OpenRouterキーがない場合はOPENAI_API_KEY(GPTモデル)を使用
      • GEMINI_API_KEYが設定されている場合のみ、要約生成にGemini 2.5 Flashを使用(Geminiキーは必須ではありません)

    コンテンツキュレーションシステム

    このプラットフォームはcurator.pyでコンテンツフィルタリングシステムを使用します:

    1. 関連性スコアリング:

      • 文書はTavilyのAI検索によってスコア付けされます
      • 継続するには最小閾値(デフォルト0.4)が必要
      • スコアは検索クエリとの関連性を反映
      • 高スコアは検索意図とのより良い一致を示す
    2. 文書処理:

      • コンテンツは正規化・クリーニングされます
      • URLは重複除去・標準化されます
      • 文書は関連性スコアでソートされます
      • 調査はバックグラウンドで非同期実行されます

    バックエンドアーキテクチャ

    プラットフォームはポーリングベースのシンプルな通信システムを実装します:

    web ui

    1. バックエンド実装:

      • 非同期サポート付きのFastAPIを使用
      • 調査タスクはバックグラウンドで実行
      • 結果はRESTエンドポイント経由で保存・アクセス
      • シンプルなジョブステータス追跡
    2. フロントエンド統合:

      • Reactフロントエンドが調査リクエストを送信
      • 追跡用のjob_idを受信
      • /research/{job_id}/reportエンドポイントをポーリング
      • 完了時に最終レポートを表示
    3. 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パッケージインストールに使用します:

    1. リポジトリをクローン:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. セットアップスクリプトを実行可能にして実行:
    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(オプション)

    手動セットアップ

    手動でセットアップしたい場合は、以下の手順に従ってください:

    1. リポジトリをクローン:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. バックエンド依存関係をインストール:
    # オプション:仮想環境を作成・アクティベート
    # 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
    
    1. フロントエンド依存関係をインストール:
    cd ui
    npm install
    
    1. 環境変数の設定:

    このプロジェクトはバックエンドとフロントエンド用に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セットアップ

    アプリケーションはDockerとDocker Composeを使用して実行できます:

    1. リポジトリをクローン:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. 環境変数の設定:

    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
    
    1. コンテナをビルド・起動:
    docker compose up --build
    

    これによりバックエンドとフロントエンドサービスが起動します:

    • バックエンドAPIはhttp://localhost:8000で利用可能
    • フロントエンドはhttp://localhost:5174で利用可能

    サービスを停止するには:

    docker compose down
    

    注意:.envの環境変数を更新する際は、コンテナを再起動する必要があります:

    docker compose down && docker compose up
    

    アプリケーションの実行

    1. バックエンドサーバーを起動(オプションを選択):
    # オプション1:直接Pythonモジュール
    python -m application.py
    
    # オプション2:UvicornでFastAPI
    uvicorn application:app --reload --port 8000
    
    1. 新しいターミナルでフロントエンドを起動:
    cd ui
    npm run dev
    
    1. http://localhost:5173でアプリケーションにアクセス

    使用方法

    モデルの選択

    アプリ右上のモデルボタンをクリックすると設定パネルが開きます。ここではOpenRouterキーを貼り付けたり(キーはブラウザにのみ保存され、各調査リクエストと一緒に送信されます)、OpenRouterのモデルカタログをプロバイダー別にロゴ付きで検索・絞り込みできます。選択したモデルは調査パイプラインのすべてのステップで使用されます。OpenRouterキーがない場合、バックエンドはOPENAI_API_KEYにフォールバックします(GEMINI_API_KEYが設定されていれば要約生成にGeminiを使用)。

    ローカル開発

    1. バックエンドサーバーを起動(オプションを選択):

      オプション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
    2. フロントエンド開発サーバーを起動:

      cd ui
      npm run dev
      
    3. http://localhost:5173でアプリケーションにアクセス

    ⚡ パフォーマンス注意: セットアップ時にuvを使用した場合、大幅に高速なパッケージインストールと依存関係解決の恩恵を受けられます。uvはRustで書かれた現代的なPythonパッケージマネージャーで、pipより10〜100倍高速です。

    デプロイメントオプション

    アプリケーションは様々なクラウドプラットフォームにデプロイできます。一般的なオプションをいくつか紹介します:

    • Docker:アプリケーションにはコンテナ化デプロイメント用のDockerfileが含まれています
    • Heroku:PythonビルドパックでGitHubから直接デプロイ
    • Google Cloud Run:自動スケーリング付きコンテナ化デプロイメントに適しています

    ニーズに最も適したプラットフォームを選択してください。アプリケーションはプラットフォーム非依存で、Pythonウェブアプリケーションがサポートされているどこでもホストできます。

    貢献

    1. リポジトリをフォーク
    2. 機能ブランチを作成(git checkout -b feature/amazing-feature)
    3. 変更をコミット(git commit -m 'Add some amazing feature')
    4. ブランチにプッシュ(git push origin feature/amazing-feature)
    5. プルリクエストを開く

    ライセンス

    このプロジェクトはApache License 2.0の下でライセンスされています - 詳細はLICENSEファイルを参照してください。

    謝辞

    • 検索APIを提供するTavily
    • その他すべてのオープンソースライブラリとその貢献者

    Star History

  • company-research-agent/README.kr.mdmarkdown · 14.7 KB

    en zh fr es jp kr

    기업 조사 에이전트 🔍

    web ui

    포괄적인 기업 조사 보고서를 생성하는 멀티 에이전트 도구입니다. 이 플랫폼은 AI 에이전트 파이프라인을 사용하여 모든 기업에 대한 정보를 수집, 정리 및 종합합니다.

    ✨온라인에서 체험해보세요! https://companyresearcher.tavily.com ✨

    https://github.com/user-attachments/assets/0e373146-26a7-4391-b973-224ded3182a9

    기능

    • 멀티소스 조사: 기업 웹사이트, 뉴스 기사, 재무 보고서, 업계 분석 등 다양한 소스에서 데이터 수집
    • AI 콘텐츠 필터링: Tavily의 관련성 점수를 사용한 콘텐츠 큐레이션
    • 비동기 처리: 조사 진행 상황을 추적하기 위한 효율적인 폴링 기반 아키텍처
    • 유연한 모델 선택: 설정에서 고른 OpenRouter 모델 하나로 모든 단계를 실행하며, 대안으로 OpenAI/Gemini API 키를 직접 사용할 수도 있습니다
    • 모던 React 프론트엔드: 진행 상황 추적과 다운로드 옵션을 갖춘 반응형 인터페이스
    • 모듈러 아키텍처: 전문화된 조사 및 처리 노드 파이프라인을 중심으로 구축

    에이전트 프레임워크

    조사 파이프라인

    이 플랫폼은 데이터를 순차적으로 처리하는 전문화된 노드를 가진 에이전트 프레임워크를 따릅니다:

    1. 조사 노드:

      • CompanyAnalyzer: 핵심 기업 정보 조사
      • IndustryAnalyzer: 시장 위치 및 트렌드 분석
      • FinancialAnalyst: 재무 지표 및 성과 데이터 수집
      • NewsScanner: 최신 뉴스 및 개발 사항 수집
    2. 처리 노드:

      • Collector: 모든 분석기에서 조사 데이터 집계
      • Curator: 콘텐츠 필터링 및 관련성 점수 매기기 구현
      • Briefing: 선택한 모델을 사용하여 카테고리별 요약 생성
      • Editor: 선택한 모델로 요약을 최종 보고서로 컴파일 및 형식 지정

      web ui

    콘텐츠 생성 아키텍처

    모든 LLM 단계(조사 노드의 쿼리 생성, Briefing, Editor)는 하나의 모델을 사용합니다. 어떤 공급자를 사용할지는 backend/llm.py에서 결정됩니다:

    1. OpenRouter (권장):

      • OpenRouter 키가 있으면(백엔드 .env의 OPENROUTER_API_KEY 또는 앱 설정 패널에 붙여넣은 키) 모든 단계에서 설정에서 선택한 OpenRouter 모델을 사용합니다
      • 기본 모델은 google/gemini-3.8-flash이며, OPENROUTER_MODEL로 변경할 수 있습니다
    2. 직접 공급자 키 (대안):

      • OpenRouter 키가 없으면 OPENAI_API_KEY(GPT 모델)를 사용합니다
      • GEMINI_API_KEY가 설정된 경우에만 브리핑에 Gemini 2.5 Flash를 사용합니다 (Gemini 키는 더 이상 필수가 아닙니다)

    콘텐츠 큐레이션 시스템

    이 플랫폼은 curator.py에서 콘텐츠 필터링 시스템을 사용합니다:

    1. 관련성 점수 매기기:

      • 문서는 Tavily의 AI 검색으로 점수가 매겨집니다
      • 계속 진행하려면 최소 임계값(기본값 0.4)이 필요합니다
      • 점수는 검색 쿼리와의 관련성을 반영합니다
      • 높은 점수는 검색 의도와의 더 나은 일치를 나타냅니다
    2. 문서 처리:

      • 콘텐츠가 정규화되고 정리됩니다
      • URL이 중복 제거되고 표준화됩니다
      • 문서가 관련성 점수로 정렬됩니다
      • 조사가 백그라운드에서 비동기적으로 실행됩니다

    백엔드 아키텍처

    플랫폼은 폴링 기반의 간단한 통신 시스템을 구현합니다:

    web ui

    1. 백엔드 구현:

      • 비동기 지원이 있는 FastAPI 사용
      • 조사 작업이 백그라운드에서 실행
      • 결과는 REST 엔드포인트를 통해 저장 및 액세스
      • 간단한 작업 상태 추적
    2. 프론트엔드 통합:

      • React 프론트엔드가 조사 요청을 제출
      • 추적을 위한 job_id 수신
      • /research/{job_id}/report 엔드포인트 폴링
      • 완료 시 최종 보고서 표시
    3. 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 패키지 설치에 사용합니다:

    1. 저장소 클론:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. 설정 스크립트를 실행 가능하게 만들고 실행:
    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 (선택사항)

    수동 설정

    수동으로 설정하려면 다음 단계를 따르세요:

    1. 저장소 클론:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. 백엔드 종속성 설치:
    # 선택사항: 가상 환경 생성 및 활성화
    # 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
    
    1. 프론트엔드 종속성 설치:
    cd ui
    npm install
    
    1. 환경 변수 설정:

    이 프로젝트는 백엔드와 프론트엔드용으로 두 개의 별도 .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 설정

    애플리케이션은 Docker 및 Docker Compose를 사용하여 실행할 수 있습니다:

    1. 저장소 클론:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. 환경 변수 설정:

    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
    
    1. 컨테이너 빌드 및 시작:
    docker compose up --build
    

    이렇게 하면 백엔드 및 프론트엔드 서비스가 시작됩니다:

    • 백엔드 API는 http://localhost:8000에서 사용 가능
    • 프론트엔드는 http://localhost:5174에서 사용 가능

    서비스를 중지하려면:

    docker compose down
    

    참고: .env의 환경 변수를 업데이트할 때 컨테이너를 다시 시작해야 합니다:

    docker compose down && docker compose up
    

    애플리케이션 실행

    1. 백엔드 서버 시작 (옵션 선택):
    # 옵션 1: 직접 Python 모듈
    python -m application.py
    
    # 옵션 2: Uvicorn으로 FastAPI
    uvicorn application:app --reload --port 8000
    
    1. 새 터미널에서 프론트엔드 시작:
    cd ui
    npm run dev
    
    1. http://localhost:5173에서 애플리케이션에 액세스

    ⚡ 성능 참고사항: 설정 중에 uv를 사용했다면 훨씬 빠른 패키지 설치와 종속성 해결의 이점을 얻을 수 있습니다. uv는 Rust로 작성된 현대적인 Python 패키지 관리자로 pip보다 10-100배 빠를 수 있습니다.

    사용법

    모델 선택

    앱 오른쪽 상단의 모델 버튼을 클릭하면 설정 패널이 열립니다. 여기서 OpenRouter 키를 붙여넣을 수 있으며(브라우저에만 저장되고 각 조사 요청과 함께 전송됨), 전체 OpenRouter 모델 카탈로그를 공급자별로 검색하거나 필터링할 수 있습니다. 선택한 모델은 조사 파이프라인의 모든 단계에 사용됩니다. OpenRouter 키가 없으면 백엔드는 OPENAI_API_KEY로 대체하며, GEMINI_API_KEY가 설정된 경우 브리핑에는 Gemini를 사용합니다.

    로컬 개발

    1. 백엔드 서버 시작 (옵션 선택):

      옵션 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
    2. 프론트엔드 개발 서버 시작:

      cd ui
      npm run dev
      
    3. http://localhost:5173에서 애플리케이션에 액세스

    배포 옵션

    애플리케이션은 다양한 클라우드 플랫폼에 배포할 수 있습니다. 몇 가지 일반적인 옵션은 다음과 같습니다:

    • Docker: 애플리케이션에는 컨테이너화된 배포를 위한 Dockerfile이 포함되어 있습니다
    • Heroku: Python 빌드팩으로 GitHub에서 직접 배포
    • Google Cloud Run: 자동 스케일링을 통한 컨테이너화된 배포에 적합

    귀하의 요구 사항에 가장 적합한 플랫폼을 선택하세요. 애플리케이션은 플랫폼에 구애받지 않으며 Python 웹 애플리케이션이 지원되는 곳이면 어디든 호스팅할 수 있습니다.

    기여

    1. 저장소 포크
    2. 기능 브랜치 생성 (git checkout -b feature/amazing-feature)
    3. 변경 사항 커밋 (git commit -m 'Add some amazing feature')
    4. 브랜치에 푸시 (git push origin feature/amazing-feature)
    5. 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_URL and receives incremental updates from GET /research/{job_id}/stream.

    Prerequisites

    • Python 3.11 or later
    • Node.js 18 or later
    • An OpenRouter API key — set in the backend .env or 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

    1. Create a virtual environment and install the backend dependencies:

      uv venv .venv
      uv pip install -r requirements.txt
      

      If you do not use uv, create the environment with python -m venv .venv and install with pip install -r requirements.txt.

    2. Create .env from 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
      
    3. Configure the frontend:

      cp ui/.env.development.example ui/.env.development.local
      cd ui && npm install && cd ..
      

      Set VITE_API_URL=http://localhost:8000 in ui/.env.development.local. Add VITE_GOOGLE_MAPS_API_KEY only when location autocomplete is needed.

    4. Start the API in one terminal:

      .venv/bin/uvicorn application:app --reload --port 8000
      
    5. 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.sh installs everything, asks for your Tavily and OpenRouter keys, and writes .env for 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 when GEMINI_API_KEY is set).

    Docker

    After creating the root .env and ui/.env.development.local files, run:

    docker compose up --build
    

    This exposes the API on port 8000 and the UI on port 5174.

    API

    MethodEndpointPurpose
    POST/researchStart a research job. Accepts optional openrouter_api_key and model.
    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"
      }'
    

    model and openrouter_api_key are optional. When openrouter_api_key is omitted, the backend uses OPENROUTER_API_KEY from its environment; when neither is set, it falls back to OpenAI/Gemini and ignores model.

    Configuration reference

    VariableRequiredUsed by
    TAVILY_API_KEYNoBackend 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_MODELNoDefault OpenRouter model (google/gemini-3.8-flash if unset)
    OPENAI_API_KEYNo*Research and editing when no OpenRouter key is available
    GEMINI_API_KEYNoBriefing synthesis when no OpenRouter key is available (falls back to OpenAI)
    MONGODB_URINoBackend job/report persistence
    VITE_API_URLYesFrontend API connection
    VITE_GOOGLE_MAPS_API_KEYNoFrontend 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

    Apache License 2.0


    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 to README.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

    en zh fr es jp kr

    智能公司研究助手 🔍

    web ui

    一个多智能体工具,用于生成全面的公司研究报告。该平台使用一系列AI智能体来收集、整理和综合任何公司的信息。

    ✨快来看看吧!https://companyresearcher.tavily.com ✨

    https://github.com/user-attachments/assets/071aa491-009b-4d76-a698-88863149e71c

    功能特点

    • 多源研究:从公司网站、新闻文章、财务报告和行业分析等多个来源收集数据
    • AI驱动的内容过滤:使用Tavily的相关性评分进行内容筛选
    • 异步处理:基于轮询的高效架构,用于跟踪研究进度
    • 灵活的模型选择:可在设置中选择任意OpenRouter模型,或使用OpenAI/Gemini直连密钥作为备选
    • 现代React前端:具有进度跟踪和下载选项的响应式UI
    • 模块化架构:使用专业研究和处理节点构建的管道

    智能体框架

    研究管道

    该平台遵循智能体框架,使用专门的节点按顺序处理数据:

    1. 研究节点:

      • CompanyAnalyzer:研究核心业务信息
      • IndustryAnalyzer:分析市场定位和趋势
      • FinancialAnalyst:收集财务指标和业绩数据
      • NewsScanner:收集最新新闻和发展动态
    2. 处理节点:

      • Collector:汇总所有分析器的研究数据
      • Curator:实现内容过滤和相关性评分
      • Briefing:使用所选模型生成特定类别的摘要
      • Editor:使用所选模型将简报编译和格式化为最终报告

      web ui

    内容生成架构

    所有LLM步骤(研究节点中的查询生成、Briefing和Editor)都使用同一个模型,模型提供方在backend/llm.py中选择:

    1. OpenRouter(推荐):

      • 当后端.env中设置了OPENROUTER_API_KEY,或用户在应用设置中粘贴了OpenRouter密钥时启用
      • 每个步骤都使用在设置中选择的OpenRouter模型
      • 默认模型为google/gemini-3.8-flash,可通过OPENROUTER_MODEL覆盖
    2. 直连密钥(备选):

      • 没有OpenRouter密钥时,后端回退到OPENAI_API_KEY(GPT模型)
      • 仅当设置了GEMINI_API_KEY时,才使用Gemini 2.5 Flash生成简报
      • Gemini密钥不再是必需的

    内容筛选系统

    该平台在curator.py中使用内容过滤系统:

    1. 相关性评分:

      • 文档由Tavily的AI驱动搜索进行评分
      • 需要达到最低阈值(默认0.4)才能继续
      • 分数反映与特定研究查询的相关性
      • 更高的分数表示与研究意图更好的匹配
    2. 文档处理:

      • 内容被标准化和清理
      • URL被去重和标准化
      • 文档按相关性分数排序
      • 研究在后台异步运行

    后端架构

    该平台实现了基于轮询的简单通信系统:

    web ui

    1. 后端实现:

      • 使用带有异步支持的FastAPI
      • 研究任务在后台运行
      • 结果通过REST端点存储和访问
      • 简单的作业状态跟踪
    2. 前端集成:

      • React前端提交研究请求
      • 接收用于跟踪的job_id
      • 轮询/research/{job_id}/report端点
      • 完成时显示最终报告
    3. 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 包安装(如果可用):

    1. 克隆仓库:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. 使安装脚本可执行并运行:
    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(可选)

    手动安装

    如果您更喜欢手动安装,请按照以下步骤操作:

    1. 克隆仓库:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. 安装后端依赖:
    # 可选:创建并激活虚拟环境
    # 使用 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
    
    1. 安装前端依赖:
    cd ui
    npm install
    
    1. 设置环境变量:

    此项目需要两个单独的 .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安装

    可以使用Docker和Docker Compose运行应用程序:

    1. 克隆仓库:
    git clone https://github.com/guy-hartstein/company-research-agent.git
    cd company-research-agent
    
    1. 设置环境变量:

    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
    
    1. 构建并启动容器:
    docker compose up --build
    

    这将启动后端和前端服务:

    • 后端API将在http://localhost:8000可用
    • 前端将在http://localhost:5174可用

    停止服务:

    docker compose down
    

    注意:更新.env中的环境变量时,需要重启容器:

    docker compose down && docker compose up
    

    运行应用程序

    1. 启动后端服务器(选择一种方式):
    # 选项1:直接Python模块
    python -m application.py
    
    # 选项2:使用Uvicorn的FastAPI
    uvicorn application:app --reload --port 8000
    
    1. 在新终端中启动前端:
    cd ui
    npm run dev
    
    1. 在http://localhost:5173访问应用程序

    ⚡ 性能说明: 如果您在设置过程中使用了 uv,您将受益于显著更快的包安装和依赖解析。uv 是一个用 Rust 编写的现代 Python 包管理器,比 pip 快 10-100 倍。

    使用方法

    选择模型

    点击应用右上角的模型按钮即可打开设置面板。您可以在此粘贴OpenRouter密钥(仅保存在您的浏览器中,并随每次研究请求一起发送),并按提供方搜索或筛选完整的OpenRouter模型目录。所选模型将用于研究管道的每个步骤;如果没有可用的OpenRouter密钥,后端会回退到OPENAI_API_KEY(设置了GEMINI_API_KEY时使用Gemini生成简报)。

    本地开发

    1. 启动后端服务器(选择一个选项):

      选项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
    2. 启动前端开发服务器:

      cd ui
      npm run dev
      
    3. 在http://localhost:5173访问应用程序

    部署选项

    该应用程序可以部署到各种云平台。以下是一些常见选项:

    • Docker:应用程序包含用于容器化部署的Dockerfile
    • Heroku:使用Python构建包直接从GitHub部署
    • Google Cloud Run:适用于具有自动扩展功能的容器化部署

    选择最适合您需求的平台。该应用程序是平台无关的,可以托管在任何支持Python Web应用程序的地方。

    贡献

    1. Fork仓库
    2. 创建特性分支(git checkout -b feature/amazing-feature)
    3. 提交更改(git commit -m 'Add amazing feature')
    4. 推送到分支(git push origin feature/amazing-feature)
    5. 打开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 graph
    
  • company-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 state
    
  • company-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 state
    
  • company-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_msg
  • company-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 info 
  • company-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(
                            Parag
  • company-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:8000 
  • company-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.