Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
be75c06
Implement Research Companion Chat Widget and Tool Calling Framework
warlock20 Jul 24, 2026
8ec4c62
feat: Add KnowledgeChunk model and migration for user knowledge storage
warlock20 Jul 24, 2026
285c3dc
fix: Update down_revision in knowledge_chunk migration to reconcile_s…
warlock20 Jul 25, 2026
cc15815
feat: Add knowledge summary and indexing functionality for knowledge …
warlock20 Jul 25, 2026
e1db570
feat: Add CompanyContext and ToolExecutor for enhanced company dashbo…
warlock20 Jul 25, 2026
03f0f4c
Implement Research Companion Chat Widget and Tool Calling Framework
warlock20 Jul 24, 2026
7d70b4d
feat: Add KnowledgeChunk model and migration for user knowledge storage
warlock20 Jul 24, 2026
c60154d
fix: Update down_revision in knowledge_chunk migration to reconcile_s…
warlock20 Jul 25, 2026
87cc0c1
feat: Add knowledge summary and indexing functionality for knowledge …
warlock20 Jul 25, 2026
f1234d3
feat: Add CompanyContext and ToolExecutor for enhanced company dashbo…
warlock20 Jul 25, 2026
9539135
Merge branch '300-argos-companion-in-other-pages' of https://github.c…
warlock20 Jul 25, 2026
da1a0ed
feat: Implement CompanionAgent with account map integration and tool …
warlock20 Jul 25, 2026
2556af2
feat: Add companion blueprint with routes for asking questions, captu…
warlock20 Jul 26, 2026
fe9fbe8
feat: Enhance companion widget with focus-specific quick actions and …
warlock20 Jul 26, 2026
759f225
feat: Implement companion agent background task with polling for results
warlock20 Jul 27, 2026
c822a79
feat: Update companion widget to be globally enabled for authenticate…
warlock20 Jul 30, 2026
0afa535
feat: integrate markdown rendering with DOMPurify for AI answers
warlock20 Jul 30, 2026
553247e
feat: enhance companion agent to provide accurate page context and im…
warlock20 Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ def create_app(config_class=Config):
app.register_blueprint(profile_bp)
from app.settings.ai_model_routes import ai_model_bp
app.register_blueprint(ai_model_bp)
from app.companion import companion_bp
app.register_blueprint(companion_bp)

# ── Auto-seed on startup ────────────────────────────────────────
from app.services.market_sweep_service import seed_market_sweeps
Expand Down
4 changes: 3 additions & 1 deletion app/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@
'css/modules/_toast.css',
'css/modules/_mental-models.css',

# Global companion widget (mounted in _base.html on any opted-in page).
'css/modules/_companion.css',

filters='rcssmin',
output='css/gen/core.%(version)s.css',
)
Expand Down Expand Up @@ -105,7 +108,6 @@
'css/modules/_company-resources.css',
'css/modules/_document-annotations.css',
'css/modules/_send-to-sector.css',
'css/modules/_companion.css',
'css/modules/_create-template.css',
'css/modules/_start-research.css',
'css/modules/_free-research.css',
Expand Down
7 changes: 7 additions & 0 deletions app/celery_tasks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@
screening_analysis_task,
)

from app.celery_tasks.tasks_companion import (
companion_ask_task,
)

__all__ = [
# Portfolio tasks
'portfolio_ai_analysis_task',
Expand All @@ -90,4 +94,7 @@

# Screening analysis tasks
'screening_analysis_task',

# Companion tasks
'companion_ask_task',
]
76 changes: 76 additions & 0 deletions app/celery_tasks/tasks_companion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# StartWithA
# Copyright (C) 2024-2026 Kiran Mathews
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""
Companion background task.

The companion agent runs a multi-hop tool-calling loop (several sequential LLM
calls) and lazy-loads a local embedding model, so it runs in a Celery worker —
not the web process — matching every other AI operation in the app.
"""

import json
import logging

from app import db, create_app
from app.models import BackgroundTask, User
from celery_app import celery

from app.services.argos.agent import CompanionAgent
from app.utils.time_utils import now_utc

logger = logging.getLogger(__name__)


@celery.task(bind=True)
def companion_ask_task(self, task_id, user_id, question, history, focus):
"""Run CompanionAgent.ask in the background, tracking status on BackgroundTask."""
app = create_app()
with app.app_context():
task = BackgroundTask.query.get(task_id)
if not task:
logger.error(f"TASK {self.request.id}: companion task {task_id} not found")
return {"status": "failed", "message": "Task not found"}

try:
task.status = 'running'
task.started_at = now_utc()
db.session.commit()

result = CompanionAgent(user_id).ask(question, history, focus)

user = User.query.get(user_id)
if user:
user.increment_ai_tokens(500)

task.status = 'completed'
task.completed_at = now_utc()
task.result = json.dumps(result)
db.session.commit()

logger.info(f"TASK {self.request.id}: companion answer for user {user_id} "
f"({result.get('hops')} hop(s))")
return {"status": "completed", **result}

except Exception as e:
logger.error(f"TASK {self.request.id}: companion ask failed - {e}", exc_info=True)
task = BackgroundTask.query.get(task_id)
if task:
task.status = 'failed'
task.completed_at = now_utc()
task.error_message = str(e)
db.session.commit()
return {"status": "failed", "message": str(e)}
2 changes: 2 additions & 0 deletions app/companies/templates/company_detail.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{% extends "main/_base_companies.html" %}
{# Opt in to the global companion, focused on this company (rendered by _base.html) #}
{% set companion_focus = {'type': 'company', 'company_id': company.id} %}

{% block title %}{{ company.name }} — Company Hub{% endblock %}

Expand Down
22 changes: 22 additions & 0 deletions app/companion/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# StartWithA
# Copyright (C) 2024-2026 Kiran Mathews
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

from flask import Blueprint

companion_bp = Blueprint('companion', __name__, url_prefix='/companion')

# Import routes to register them with the blueprint.
from app.companion import routes # noqa: E402,F401
156 changes: 156 additions & 0 deletions app/companion/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# StartWithA
# Copyright (C) 2024-2026 Kiran Mathews
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""
Global companion routes.

- POST /companion/ask — agentic chat grounded in the user's whole account
- POST /companion/capture — quick-capture an insight as a JournalEntry
- GET /companion/warnings — zero-token proactive warnings for a company

Focus is a page hint: {'type': 'company'|'research'|'portfolio', 'id': int, 'step': int}.
Every handler is scoped to current_user; the agent/executor enforce ownership below.
"""

import logging

from flask import request
from flask_login import current_user, login_required

from app import db
from app.companion import companion_bp
from app.models.journal import JournalEntry
from app.models.research import ResearchProject
from app.models.company import Company
from app.models.background_task import BackgroundTask
from app.services.argos import ArgosService
from app.services.background_tasks import BackgroundTaskService
from app.utils.time_utils import now_utc
from app.utils.response_utils import json_success, json_error, json_validation_error
from app.utils.db_utils import safe_add_and_commit

logger = logging.getLogger(__name__)


def _capture_link(focus):
"""
Resolve (company_id, project_id) a capture should attach to.

A research project always belongs to a company, so a research capture links both;
a company capture links only the company. Ids come from focus explicitly and are
VALIDATED against the current user — an unowned or missing id links nothing rather
than trusting client input. `type` is a UI hint only and isn't used here.
"""
project_id = focus.get('project_id')
if project_id:
project = ResearchProject.query.filter_by(
id=project_id, user_id=current_user.id).first()
if project:
return project.company_id, project.id # company derived from the project
return None, None

company_id = focus.get('company_id')
if company_id:
company = Company.query.filter_by(
id=company_id, user_id=current_user.id).first()
return (company.id if company else None), None

return None, None


@companion_bp.route('/ask', methods=['POST'])
@login_required
def ask():
"""Kick off the agentic companion in the background. Returns a task_id to poll.

The agent runs a multi-hop tool-calling loop + local embedding, so it runs in a
Celery worker (like every other AI op) rather than blocking the web request.
"""
data = request.get_json(silent=True) or {}
question = (data.get('question') or '').strip()
if not question:
return json_validation_error('Question is required')

history = data.get('history') or []
focus = data.get('focus') or {}

try:
task_id = BackgroundTaskService.start_companion_ask(
current_user.id, question, history, focus)
return json_success('Companion working', data={'task_id': task_id})
except Exception as e:
logger.error(f"Companion ask kickoff failed: {e}")
return json_error(str(e), status_code=500)


@companion_bp.route('/ask/status/<task_id>', methods=['GET'])
@login_required
def ask_status(task_id):
"""Poll a companion task. Returns {status, result?/error?}. Ownership-checked."""
task = BackgroundTask.query.filter_by(id=task_id, user_id=current_user.id).first()
if not task:
return json_error('Task not found', status_code=404)

status = BackgroundTaskService.get_task_status(task_id)
return json_success('Task status', data=status)


@companion_bp.route('/capture', methods=['POST'])
@login_required
def capture():
"""Capture a finding from an external source as a JournalEntry."""
data = request.get_json(silent=True) or {}
text = (data.get('text') or '').strip()
if not text:
return json_validation_error('Text is required')

source_title = (data.get('source_title') or '').strip() or None
url = (data.get('url') or '').strip() or None
focus = data.get('focus') or {}
company_id, project_id = _capture_link(focus)

entry = JournalEntry(
user_id=current_user.id,
title=source_title or 'External capture',
entry_type='observation',
content=text,
source=source_title,
source_url=url,
company_id=company_id,
project_id=project_id,
tags=['external_capture'],
created_at=now_utc(),
)
if safe_add_and_commit(db.session, entry, 'companion capture'):
return json_success('Captured', data={'entry_id': entry.id})
return json_error('Failed to save capture', status_code=500)


@companion_bp.route('/warnings', methods=['GET'])
@login_required
def warnings():
"""Proactive warnings for a company — pattern/journal/mistake history. Zero token cost."""
company_id = request.args.get('company_id', type=int)
if not company_id:
return json_validation_error('company_id is required')

try:
argos = ArgosService(user_id=current_user.id)
return json_success('Warnings loaded',
data={'warnings': argos.get_warnings_by_company(company_id)})
except Exception as e:
logger.error(f"Companion warnings failed: {e}")
return json_error(str(e), status_code=500)
5 changes: 5 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@
from .user_ai_preferences import (
UserAIPreference,
)
from .knowledge_chunk import (
KnowledgeChunk,
)



Expand Down Expand Up @@ -226,4 +229,6 @@ def load_user(user_id):
'MarketSweepDecision',
# User AI Preferences
'UserAIPreference',
# Companion knowledge index
'KnowledgeChunk',
]
66 changes: 66 additions & 0 deletions app/models/knowledge_chunk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# StartWithA
# Copyright (C) 2024-2026 Kiran Mathews
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""
KnowledgeChunk — one embedded summary of a piece of the user's knowledge.

The companion's `search_my_knowledge` tool retrieves across four sources
(research findings, journal entries, decision journals, saved resources).
Each source item is summarised, embedded once (BGE-base, 768 dims, pgvector),
and stored here keyed by (source_type, source_id) so re-indexing is idempotent.

Matches the existing embedding infra: `EmbeddingStore` uses the same
`Vector(768)` pgvector column type.
"""

from pgvector.sqlalchemy import Vector

from app import db
from app.utils.time_utils import now_utc


class KnowledgeChunk(db.Model):
"""An embedded, summarised chunk of one user's knowledge."""

__tablename__ = 'knowledge_chunk'

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(
db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False, index=True)
company_id = db.Column(
db.Integer, db.ForeignKey('company.id', ondelete='CASCADE'),
nullable=True, index=True)

# 'finding' | 'journal' | 'decision' | 'resource'
source_type = db.Column(db.String(20), nullable=False, index=True)
source_id = db.Column(db.Integer, nullable=False)

title = db.Column(db.String(300))
summary = db.Column(db.Text, nullable=False) # the embedded string

# BGE-base-en-v1.5, 768 dims (same as EmbeddingStore). Requires pgvector.
embedding = db.Column(Vector(768), nullable=True)

token_estimate = db.Column(db.Integer, default=0)
updated_at = db.Column(db.DateTime, default=now_utc, onupdate=now_utc)

__table_args__ = (
db.UniqueConstraint('source_type', 'source_id', name='uq_knowledge_source'),
)

def __repr__(self):
return f'<KnowledgeChunk {self.source_type} #{self.source_id}>'
Loading
Loading