diff --git a/app/__init__.py b/app/__init__.py index 21010096..640f2f9f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/assets.py b/app/assets.py index 73ecffe0..eb78a4af 100644 --- a/app/assets.py +++ b/app/assets.py @@ -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', ) @@ -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', diff --git a/app/celery_tasks/__init__.py b/app/celery_tasks/__init__.py index 11f0b8ec..4ddc4d35 100644 --- a/app/celery_tasks/__init__.py +++ b/app/celery_tasks/__init__.py @@ -65,6 +65,10 @@ screening_analysis_task, ) +from app.celery_tasks.tasks_companion import ( + companion_ask_task, +) + __all__ = [ # Portfolio tasks 'portfolio_ai_analysis_task', @@ -90,4 +94,7 @@ # Screening analysis tasks 'screening_analysis_task', + + # Companion tasks + 'companion_ask_task', ] diff --git a/app/celery_tasks/tasks_companion.py b/app/celery_tasks/tasks_companion.py new file mode 100644 index 00000000..93180415 --- /dev/null +++ b/app/celery_tasks/tasks_companion.py @@ -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 . + +""" +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)} diff --git a/app/companies/templates/company_detail.html b/app/companies/templates/company_detail.html index f3b418cd..bbf53adb 100644 --- a/app/companies/templates/company_detail.html +++ b/app/companies/templates/company_detail.html @@ -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 %} diff --git a/app/companion/__init__.py b/app/companion/__init__.py new file mode 100644 index 00000000..d42155fc --- /dev/null +++ b/app/companion/__init__.py @@ -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 . + +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 diff --git a/app/companion/routes.py b/app/companion/routes.py new file mode 100644 index 00000000..9dce678a --- /dev/null +++ b/app/companion/routes.py @@ -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 . + +""" +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/', 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) diff --git a/app/models/__init__.py b/app/models/__init__.py index 7a76d343..33170ee5 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -124,6 +124,9 @@ from .user_ai_preferences import ( UserAIPreference, ) +from .knowledge_chunk import ( + KnowledgeChunk, +) @@ -226,4 +229,6 @@ def load_user(user_id): 'MarketSweepDecision', # User AI Preferences 'UserAIPreference', + # Companion knowledge index + 'KnowledgeChunk', ] diff --git a/app/models/knowledge_chunk.py b/app/models/knowledge_chunk.py new file mode 100644 index 00000000..15898d41 --- /dev/null +++ b/app/models/knowledge_chunk.py @@ -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 . + +""" +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'' diff --git a/app/portfolio/templates/portfolio_dashboard.html b/app/portfolio/templates/portfolio_dashboard.html index 0eed2e9e..147624a7 100644 --- a/app/portfolio/templates/portfolio_dashboard.html +++ b/app/portfolio/templates/portfolio_dashboard.html @@ -1,4 +1,6 @@ {% extends "main/_base_portfolio.html" %} +{# Opt in to the global companion, focused on the whole portfolio (rendered by _base.html) #} +{% set companion_focus = {'type': 'portfolio'} %} {% block title %}Portfolio{% endblock %} diff --git a/app/research_workflow/companion_routes.py b/app/research_workflow/companion_routes.py index edaea68a..8aa41390 100644 --- a/app/research_workflow/companion_routes.py +++ b/app/research_workflow/companion_routes.py @@ -15,14 +15,21 @@ # along with this program. If not, see . """ -Research Companion Routes - -API endpoints for companion features: -- POST /companion//brief — generate research brief -- POST /companion//ask — live companion chat -- POST /companion//counter-evidence — counter-evidence for a finding -- POST /companion//wrapup — session wrap-up -- POST /companion//capture — quick-capture (creates JournalEntry) +Research Companion Routes (project-scoped, legacy). + +The global agentic companion now lives at the `/companion` blueprint +(`app/companion/`): chat via POST /companion/ask (async, Celery + poll) and +quick-capture via POST /companion/capture. The widget uses those everywhere. + +These project-scoped routes remain for research-only features still wired to them: +- POST /companion//brief — pre-session research brief +- POST /companion//wrapup — session wrap-up (still used by the widget) +- GET /companion//warnings — zero-token warnings + +DEPRECATED (superseded by /companion/ask and /companion/capture; kept for any +legacy callers, no longer used by the widget): +- POST /companion//ask +- POST /companion//capture """ import logging diff --git a/app/research_workflow/templates/execute_kill_checklist_step.html b/app/research_workflow/templates/execute_kill_checklist_step.html index 88a99678..22ffa7f4 100644 --- a/app/research_workflow/templates/execute_kill_checklist_step.html +++ b/app/research_workflow/templates/execute_kill_checklist_step.html @@ -1,4 +1,6 @@ {% extends "main/_base_companies.html" %} +{# Opt in to the global companion (rendered by _base.html) #} +{% set companion_focus = {'type': 'research', 'company_id': project.company_id, 'project_id': project.id, 'step': step_index | default(0)} %} {% block content %}
@@ -196,5 +198,5 @@
Screening Tips
setInterval(saveProgress, 30000); -{% include 'partials/_companion_widget.html' %} +{# companion now rendered globally by _base.html via companion_enabled #} {% endblock %} \ No newline at end of file diff --git a/app/research_workflow/templates/execute_step.html b/app/research_workflow/templates/execute_step.html index 1cea68dd..4ccba458 100644 --- a/app/research_workflow/templates/execute_step.html +++ b/app/research_workflow/templates/execute_step.html @@ -1,4 +1,6 @@ {% extends "main/_base_companies.html" %} +{# Opt in to the global companion (rendered by _base.html) #} +{% set companion_focus = {'type': 'research', 'company_id': project.company_id, 'project_id': project.id, 'step': step_index | default(0)} %} {% block content %}
@@ -196,7 +198,7 @@

-{% include 'partials/_companion_widget.html' %} +{# companion now rendered globally by _base.html via companion_enabled #} {% endblock %} {% block scripts %} diff --git a/app/research_workflow/templates/free_research_step.html b/app/research_workflow/templates/free_research_step.html index ed6dea93..267cfa88 100644 --- a/app/research_workflow/templates/free_research_step.html +++ b/app/research_workflow/templates/free_research_step.html @@ -1,4 +1,6 @@ {% extends "main/_base_companies.html" %} +{# Opt in to the global companion (rendered by _base.html) #} +{% set companion_focus = {'type': 'research', 'company_id': project.company_id, 'project_id': project.id, 'step': step_index | default(0)} %} {% block head %} @@ -207,7 +209,7 @@
Ready to move on?
-{% include 'partials/_companion_widget.html' %} +{# companion now rendered globally by _base.html via companion_enabled #} {% include 'components/send_to_sector_modal.html' %} {% include 'components/company_resources_modal.html' %} {% endblock %} diff --git a/app/research_workflow/templates/partials/_companion_widget.html b/app/research_workflow/templates/partials/_companion_widget.html deleted file mode 100644 index 1ae32496..00000000 --- a/app/research_workflow/templates/partials/_companion_widget.html +++ /dev/null @@ -1,318 +0,0 @@ -{# - Research Companion — Floating Chat Widget - - A floating action button (FAB) + slide-up chat panel for live companion chat. - Wired to POST /companion//ask for Q&A during research sessions. - - Include in research step templates with: - {% include 'partials/_companion_widget.html' %} - - Requires: project (with .id), step_index, step.type - CSS: modules/_companion.css -#} - -{% set _project_id = project.id if project is defined and project and project.id else 0 %} -{% set _step_index = step_index if step_index is defined else 0 %} - -{% if _project_id %} - - - - -
- -
-
- - Research Companion -
-
- -
-
- - -
-
- Companion ready — facts only, opinions are yours. -
-
- - -
- - - -
- - -
- - -
-
- - - - - -{% endif %} diff --git a/app/services/ai/ai_service.py b/app/services/ai/ai_service.py index b9897d7e..6e668af3 100644 --- a/app/services/ai/ai_service.py +++ b/app/services/ai/ai_service.py @@ -65,6 +65,7 @@ from .providers.gemini import GeminiProvider from .providers.claude import ClaudeProvider from .providers.deepseek import DeepseekProvider +from app.services.ai.tool_calling import run_tool_loop logger = logging.getLogger(__name__) @@ -265,7 +266,51 @@ def generate_text( stop_sequences=stop_sequences, **kwargs ) - + + def generate_with_tools( + self, + messages, + tools, + executor, + system=None, + task=None, + provider=None, + model=None, + max_hops=5, + max_tokens=1024, + temperature=0.3, + ): + """ + Run an agentic tool-calling loop. + + Routes to a tool-capable provider (Gemini/Claude) and drives the + model ⇄ tool conversation via `run_tool_loop`. See + `app.services.ai.tool_calling` for the neutral contract. + + Args: + messages: neutral transcript (Message objects or plain dicts) + tools: list[ToolSpec] the model may call + executor: ToolExecutorFn mapping a ToolCall to a ToolResult + system: optional system prompt + task/provider/model: routing controls (as for generate_text) + max_hops: maximum tool-execution rounds + + Returns: + ToolLoopResult(text, hops, calls) + + Raises: + RuntimeError: if the routed provider does not support tools + """ + + ai_provider = self._get_provider(task, provider, model) + if not ai_provider.supports_tools(): + raise RuntimeError( + f"Provider {ai_provider.model_name} does not support tool-calling") + return run_tool_loop( + ai_provider, messages, tools, executor, + system=system, max_hops=max_hops, + max_tokens=max_tokens, temperature=temperature) + def generate_json( self, prompt: str, diff --git a/app/services/ai/prompts/companion/companion_agent.yaml b/app/services/ai/prompts/companion/companion_agent.yaml new file mode 100644 index 00000000..da785228 --- /dev/null +++ b/app/services/ai/prompts/companion/companion_agent.yaml @@ -0,0 +1,52 @@ +name: "companion_agent" +description: "Global agentic companion — answers grounded in the user's own account via tools" +version: "1.0" +category: "companion" + +preferred_provider: "gemini" +model: "gemini-3-pro-preview" +# Per-turn output budget. Tool-call turns are naturally short; this headroom is for +# the final answer when a question spans several sources and needs real synthesis. +max_tokens: 4096 +temperature: 0.3 + +system_context: | + You are the user's research companion. You have tools to look up their portfolio, + companies, research projects, journal notes, saved links, and logged mistakes. + Decide which tools to call to ground every answer in THEIR OWN DATA, and chain + calls when a question spans several of them (e.g. resolve their biggest position, + then search what they found about it). + + CRITICAL RULES (never break): + 1. You surface FACTS, DATA, and INFORMATION from the user's own account. You NEVER + give investment opinions or recommendations. Facts only — opinions are yours. + 2. If asked "Should I invest?", "Is this a good company?", "What do you think?", or + any opinion-seeking question, respond: "Forming the investment opinion is your + job — that's where your edge comes from. I can show you what your data says and + what you might be missing." Then surface the relevant facts via tools. + 3. Reference the user's own history as facts: "Your mistake log shows..." not + "You tend to...". Frame gaps as "X has no findings yet", never "You should...". + 4. Prefer calling a tool over guessing. If the account map already answers a purely + navigational question, answer directly. Always ground claims in what you found. + 5. The ACCOUNT MAP below is the user's ENTIRE account (all holdings, research, and + history) — it is NOT the page they are on. For "what is this page?" / "where am I?" + questions, use CURRENT PAGE below. You can see the page's title and URL but NOT its + on-screen contents; say which page it is and what you can help with there. NEVER + infer or invent the page name from the account map. + +template: | + ACCOUNT MAP — the user's ENTIRE account (all holdings, research, history). This is + NOT the page the user is on: + {account_map} + + CURRENT PAGE — where the user actually is right now: + {focus} + + Use the tools as needed to answer the user's question. Ground every claim in what + you find. + +output_format: | + Plain text. Be as concise as the question allows — a sentence or two for simple + lookups, a fuller answer when connecting facts across several sources. Name the + source of each fact (e.g. "your journal", "your mistake log", "your research on X"). + Use short bullets only when listing several distinct data points. diff --git a/app/services/ai/prompts/companion/knowledge_summary.yaml b/app/services/ai/prompts/companion/knowledge_summary.yaml new file mode 100644 index 00000000..c82a6206 --- /dev/null +++ b/app/services/ai/prompts/companion/knowledge_summary.yaml @@ -0,0 +1,26 @@ +name: "knowledge_summary" +description: "Compress one piece of a user's research knowledge into a short, embeddable factual summary" +version: "1.0" +category: "companion" + +preferred_provider: "gemini" +model: "gemini-3-flash-preview" +max_tokens: 120 +temperature: 0.2 + +system_context: | + You compress a single piece of an investor's own research knowledge into a short + summary that will be embedded for later semantic retrieval. + + RULES: + 1. Facts only — no opinions, no recommendations, no added interpretation. + 2. Preserve the concrete specifics (names, numbers, claims); drop filler. + 3. Neutral, third-person phrasing. No "the user" preamble — state the content. + +template: | + Summarise the following {source_type} in {max_words} words or fewer, facts only: + + {content} + +output_format: | + Plain text, a single compact paragraph, no headers or bullets. diff --git a/app/services/ai/providers/base.py b/app/services/ai/providers/base.py index 91b94fbd..b6f30f4d 100644 --- a/app/services/ai/providers/base.py +++ b/app/services/ai/providers/base.py @@ -148,10 +148,40 @@ def model_name(self) -> str: """ pass + def supports_tools(self) -> bool: + """ + Whether this provider implements agentic tool-calling (`generate_turn`). + + Defaults to False; providers that implement `generate_turn` override to True. + """ + return False + + def generate_turn(self, messages, tools, system=None, max_tokens=1024, temperature=0.3): + """ + Execute ONE turn of a tool-calling conversation. + + Providers that support tools translate the neutral `messages`/`tools` + into their SDK format, call the model, and return a + `app.services.ai.tool_calling.TurnResult` (either prose text or tool-call + requests). The orchestration loop (`run_tool_loop`) owns the iteration. + + Args: + messages: neutral transcript (list of dicts with 'role'/'content'; + assistant tool requests carry 'tool_calls', results carry 'tool_call_id') + tools: list[ToolSpec] the model may call (empty to force a prose answer) + system: optional system prompt + max_tokens/temperature: generation controls + + Raises: + NotImplementedError: if the provider does not support tools + """ + raise NotImplementedError( + f"{type(self).__name__} does not support tool-calling") + def count_tokens(self, text: str) -> int: """ Estimate token count for text. - + Default implementation uses rough approximation. Override for provider-specific token counting. diff --git a/app/services/ai/providers/claude.py b/app/services/ai/providers/claude.py index c85b6c46..20924347 100644 --- a/app/services/ai/providers/claude.py +++ b/app/services/ai/providers/claude.py @@ -47,6 +47,7 @@ from ..prompt_service import get_intelligence_prompt from ..analytics import log_prompt_usage from app.utils.time_utils import now_utc +from app.services.ai.tool_calling import Message, TurnResult, ToolCall logger = logging.getLogger(__name__) @@ -275,6 +276,64 @@ def generate_text( error_message=error_message ) + def supports_tools(self) -> bool: + return True + + def _messages_to_claude(self, messages): + """Translate a neutral transcript (Message objects or dicts) into + Anthropic message dicts. Tool results become tool_result blocks in a + user message; assistant tool requests become tool_use blocks.""" + + out = [] + for raw in messages: + msg = Message.coerce(raw) + if msg.role == 'tool': + out.append({'role': 'user', 'content': [{ + 'type': 'tool_result', + 'tool_use_id': msg.tool_call_id, + 'content': msg.content, + }]}) + elif msg.role == 'assistant': + blocks = [] + if msg.content: + blocks.append({'type': 'text', 'text': msg.content}) + for call in msg.tool_calls or []: + blocks.append({'type': 'tool_use', 'id': call.id, + 'name': call.name, 'input': call.arguments}) + out.append({'role': 'assistant', 'content': blocks}) + else: # user + out.append({'role': 'user', 'content': msg.content}) + return out + + def generate_turn(self, messages, tools, system=None, max_tokens=1024, temperature=0.3): + """One turn of a tool-calling conversation. Returns a TurnResult.""" + + create_params = { + 'model': self._model_enum.model_id, + 'max_tokens': max_tokens, + 'temperature': temperature, + 'messages': self._messages_to_claude(messages), + } + if system: + create_params['system'] = system + if tools: + create_params['tools'] = [{ + 'name': t.name, + 'description': t.description, + 'input_schema': t.parameters, + } for t in tools] + + response = self._client.messages.create(**create_params) + + calls, text = [], None + for block in response.content: + if block.type == 'tool_use': + calls.append(ToolCall(id=block.id, name=block.name, + arguments=dict(block.input))) + elif block.type == 'text': + text = (text or '') + block.text + return TurnResult(text=text, tool_calls=calls) + def generate_json( self, prompt: str, diff --git a/app/services/ai/providers/gemini.py b/app/services/ai/providers/gemini.py index 7faa4a1c..c91172ee 100644 --- a/app/services/ai/providers/gemini.py +++ b/app/services/ai/providers/gemini.py @@ -42,6 +42,7 @@ from .base import AIProvider from ..config import get_ai_config, AIModel, AIProvider as AIProviderEnum +from app.services.ai.tool_calling import Message, TurnResult, ToolCall logger = logging.getLogger(__name__) @@ -205,6 +206,72 @@ def generate_text( logger.error(f"Gemini generate_text error: {str(e)}") raise + def supports_tools(self) -> bool: + return True + + def _messages_to_gemini(self, messages): + """Translate a neutral transcript (Message objects or dicts) into + google-genai Content objects.""" + contents = [] + for raw in messages: + msg = Message.coerce(raw) + if msg.role == 'tool': + # A tool result is returned to the model as a function_response part. + contents.append(genai_types.Content( + role='tool', + parts=[genai_types.Part.from_function_response( + name=msg.tool_call_id or 'tool', + response={'result': msg.content})])) + elif msg.role == 'assistant': + parts = [] + if msg.content: + parts.append(genai_types.Part(text=msg.content)) + for call in msg.tool_calls or []: + part_kwargs = { + 'function_call': genai_types.FunctionCall( + name=call.name, args=call.arguments), + } + # Gemini 3 requires the thought_signature to be echoed back + # with the function call it originally produced. + if call.signature is not None: + part_kwargs['thought_signature'] = call.signature + parts.append(genai_types.Part(**part_kwargs)) + contents.append(genai_types.Content(role='model', parts=parts)) + else: # 'user' (or anything else) → user turn + contents.append(genai_types.Content( + role='user', parts=[genai_types.Part(text=msg.content)])) + return contents + + def generate_turn(self, messages, tools, system=None, max_tokens=1024, temperature=0.3): + """One turn of a tool-calling conversation. Returns a TurnResult.""" + contents = self._messages_to_gemini(messages) + config_dict = {'temperature': temperature, 'max_output_tokens': max_tokens} + if system: + config_dict['system_instruction'] = system + if tools: + config_dict['tools'] = [genai_types.Tool(function_declarations=[ + genai_types.FunctionDeclaration( + name=t.name, description=t.description, parameters=t.parameters) + for t in tools])] + + response = self._client.models.generate_content( + model=self._model_enum.model_id, contents=contents, config=config_dict) + + calls, text = [], None + candidate = response.candidates[0] if getattr(response, 'candidates', None) else None + parts = candidate.content.parts if candidate and candidate.content else [] + for part in parts: + fc = getattr(part, 'function_call', None) + if fc: + # Gemini function calls have no id; use the name as the correlation id. + # Preserve thought_signature — Gemini 3 requires it echoed back. + calls.append(ToolCall( + id=fc.name, name=fc.name, arguments=dict(fc.args or {}), + signature=getattr(part, 'thought_signature', None))) + elif getattr(part, 'text', None): + text = (text or '') + part.text + return TurnResult(text=text, tool_calls=calls) + def generate_json( self, prompt: str, diff --git a/app/services/ai/tool_calling.py b/app/services/ai/tool_calling.py new file mode 100644 index 00000000..4a30012c --- /dev/null +++ b/app/services/ai/tool_calling.py @@ -0,0 +1,163 @@ +# 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 . + +""" +Provider-agnostic tool-calling primitives. + +The types here describe an agentic tool-calling conversation in a neutral shape, +independent of any provider SDK. Providers translate to/from their own formats +(Gemini FunctionDeclaration, Claude tool_use blocks); the orchestration loop +(`run_tool_loop`, added in Task 4) drives the conversation using only these types. + +- ToolSpec: the menu entry the model sees (name + description + JSON-Schema params) +- ToolCall: the model's request to run a tool +- ToolResult: the executed result fed back to the model +- TurnResult: one model turn — either prose text, or tool-call requests +- ToolLoopResult: the final outcome of the whole loop +""" + +from dataclasses import dataclass, field +from typing import Callable, List, Dict, Any, Optional + + +@dataclass +class ToolSpec: + """A tool the model may call. `parameters` is a JSON-Schema object.""" + name: str + description: str + parameters: Dict[str, Any] + + +@dataclass +class ToolCall: + """A model's request to invoke a tool. + + `signature` is an opaque, provider-specific token that some models (e.g. + Gemini 3's `thought_signature`) attach to a function call and REQUIRE to be + echoed back when the call is replayed into the next turn's history. Providers + populate it on extraction and restore it when rebuilding the transcript; + providers that don't use it leave it None. + """ + id: str + name: str + arguments: Dict[str, Any] + signature: Optional[Any] = None + + +@dataclass +class ToolResult: + """The result of executing a ToolCall, fed back to the model.""" + id: str + content: str + + +@dataclass +class Message: + """ + One entry in the neutral transcript every provider must speak. + + This is the provider-agnostic contract: `run_tool_loop` and callers build + `Message`s (or plain dicts, coerced at the boundary), and each provider's + `generate_turn` translates them into its own SDK format. + + - role 'user' → content + - role 'assistant' → content and/or tool_calls (a model turn) + - role 'tool' → content is a tool result, tool_call_id correlates it + """ + role: str + content: str = '' + tool_calls: List["ToolCall"] = field(default_factory=list) + tool_call_id: Optional[str] = None + + @classmethod + def coerce(cls, m: Any) -> "Message": + """Accept an existing Message or a plain dict; return a Message.""" + if isinstance(m, cls): + return m + return cls( + role=m.get('role', 'user'), + content=m.get('content', '') or '', + tool_calls=m.get('tool_calls', []) or [], + tool_call_id=m.get('tool_call_id'), + ) + + +# An executor takes a ToolCall and returns its ToolResult. +ToolExecutorFn = Callable[[ToolCall], ToolResult] + + +@dataclass +class ToolLoopResult: + """Final outcome of an agentic tool-calling loop.""" + text: str + hops: int + calls: List[ToolCall] = field(default_factory=list) + + +@dataclass +class TurnResult: + """One model turn: either prose `text`, or `tool_calls` requesting execution.""" + text: Optional[str] + tool_calls: List[ToolCall] = field(default_factory=list) + + +def run_tool_loop(provider, messages, tools, executor, + system=None, max_hops=5, max_tokens=1024, temperature=0.3) -> ToolLoopResult: + """ + Drive an agentic tool-calling conversation. + + The provider owns ONE turn (`generate_turn`); this function owns the loop: + ask the model with the tool menu; if it returns tool calls, execute them, + append the results to the transcript, and ask again; stop when the model + returns prose, or when the hop cap is reached (then force a final, + tool-free answer so the user always gets prose back). + + Args: + provider: object with `generate_turn(messages, tools, system, max_tokens, temperature)` + messages: neutral transcript — list of dicts with 'role' and 'content'; + assistant tool requests carry 'tool_calls', tool results carry 'tool_call_id' + tools: list[ToolSpec] the model may call + executor: ToolExecutorFn mapping a ToolCall to a ToolResult + system: optional system prompt + max_hops: maximum tool-execution rounds before forcing an answer + """ + transcript: List[Message] = [Message.coerce(m) for m in messages] + all_calls: List[ToolCall] = [] + hops = 0 + while True: + turn = provider.generate_turn(transcript, tools, system=system, + max_tokens=max_tokens, temperature=temperature) + + # Model wants to call tools and we still have budget: execute + continue. + if turn.tool_calls and hops < max_hops: + hops += 1 + transcript.append(Message(role='assistant', content=turn.text or '', + tool_calls=turn.tool_calls)) + for call in turn.tool_calls: + all_calls.append(call) + res = executor(call) + transcript.append(Message(role='tool', tool_call_id=call.id, + content=res.content)) + continue + + # Hop cap hit while the model still wants tools: force a tool-free answer. + if turn.tool_calls and hops >= max_hops: + final = provider.generate_turn(transcript, [], system=system, + max_tokens=max_tokens, temperature=temperature) + return ToolLoopResult(text=final.text or '', hops=hops, calls=all_calls) + + # Model returned prose: done. + return ToolLoopResult(text=turn.text or '', hops=hops, calls=all_calls) diff --git a/app/services/argos/account_map.py b/app/services/argos/account_map.py new file mode 100644 index 00000000..9e9d450d --- /dev/null +++ b/app/services/argos/account_map.py @@ -0,0 +1,161 @@ +# 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 . + +""" +Account map — the compact, cheap DB skeleton the companion agent sees every turn. + +Pure DB queries, no LLM. Gives the model the *shape* of the user's account +(holdings + weights, researched companies + project state) plus the current page +focus, so it knows what exists and what to fetch via tools. Kept small on purpose +(a few hundred tokens once rendered). +""" + +from sqlalchemy import func +from sqlalchemy.orm import joinedload + +from app import db +from app.models.portfolio import PortfolioPosition +from app.models.research import ResearchProject +from app.models.journal import JournalEntry, PatternRecognition +from app.models.idea_pipeline import MistakeLog + + +def _counts_by_company(model, user_id): + """{company_id -> count} for a user, in one grouped query. + + The None key holds rows not tied to a company (general notes / mistakes). + """ + rows = ( + db.session.query(model.company_id, func.count(model.id)) + .filter(model.user_id == user_id) + .group_by(model.company_id) + .all() + ) + return {company_id: count for company_id, count in rows} + + +def build_account_map(user_id, focus=None): + """Assemble the account skeleton for one user. Returns a plain dict.""" + # Cheap grouped counts of the user's journal + mistake history. + notes_by_company = _counts_by_company(JournalEntry, user_id) + mistakes_by_company = _counts_by_company(MistakeLog, user_id) + patterns_total = PatternRecognition.query.filter_by(user_id=user_id).count() + journal_general = notes_by_company.get(None, 0) + mistakes_general = mistakes_by_company.get(None, 0) + positions = ( + PortfolioPosition.query + .options(joinedload(PortfolioPosition.company)) + .filter_by(user_id=user_id, is_active=True) + .all() + ) + total_value = sum(float(p.current_value or 0) for p in positions) or 0.0 + + holdings = [] + for p in positions: + company = p.company + value = float(p.current_value or 0) + holdings.append({ + 'company_id': p.company_id, + 'name': company.name if company else 'Unknown', + 'ticker': company.ticker_symbol if company else None, + 'weight_pct': round(value / total_value * 100, 1) if total_value else 0.0, + 'notes': notes_by_company.get(p.company_id, 0), + 'mistakes': mistakes_by_company.get(p.company_id, 0), + }) + holdings.sort(key=lambda h: h['weight_pct'], reverse=True) + + researched = ( + ResearchProject.query + .options(joinedload(ResearchProject.company)) + .filter_by(user_id=user_id) + .all() + ) + researched_companies = [] + for proj in researched: + company = proj.company + state = proj.status or 'in_progress' + if proj.current_step_index is not None: + state = f"{state} (step {proj.current_step_index + 1})" + researched_companies.append({ + 'company_id': proj.company_id, + 'name': company.name if company else 'Unknown', + 'project_state': state, + 'notes': notes_by_company.get(proj.company_id, 0), + 'mistakes': mistakes_by_company.get(proj.company_id, 0), + }) + + company_ids = {h['company_id'] for h in holdings} | { + r['company_id'] for r in researched_companies} + + return { + 'holdings': holdings, + 'researched_companies': researched_companies, + 'focus': focus or {}, + 'counts': { + 'holdings': len(holdings), + 'companies': len(company_ids), + 'journal_entries': sum(notes_by_company.values()), + 'journal_general': journal_general, + 'mistakes': sum(mistakes_by_company.values()), + 'mistakes_general': mistakes_general, + 'patterns': patterns_total, + }, + } + + +def render_account_map(account_map): + """Render the account map as a compact text block for the prompt.""" + lines = [] + focus = account_map.get('focus') or {} + if focus.get('type'): + target = f" (id={focus['id']})" if focus.get('id') else '' + lines.append(f"CURRENT FOCUS: {focus['type']}{target}") + + def _tags(item): + parts = [] + if item.get('notes'): + parts.append(f"{item['notes']} note(s)") + if item.get('mistakes'): + parts.append(f"{item['mistakes']} mistake(s)") + return f" · {', '.join(parts)}" if parts else '' + + holdings = account_map.get('holdings', []) + lines.append(f"PORTFOLIO — {len(holdings)} holding(s):") + if holdings: + for h in holdings: + ticker = f" ({h['ticker']})" if h.get('ticker') else '' + lines.append(f" - {h['name']}{ticker}: {h['weight_pct']}%{_tags(h)}") + else: + lines.append(" - none") + + researched = account_map.get('researched_companies', []) + if researched: + lines.append(f"RESEARCHED COMPANIES — {len(researched)}:") + for r in researched: + lines.append(f" - {r['name']}: {r['project_state']}{_tags(r)}") + + counts = account_map.get('counts', {}) + history = [] + if counts.get('mistakes'): + history.append(f"{counts['mistakes']} logged mistake(s)") + if counts.get('patterns'): + history.append(f"{counts['patterns']} behavioural pattern(s)") + if counts.get('journal_general'): + history.append(f"{counts['journal_general']} general note(s)") + if history: + lines.append("HISTORY: " + ", ".join(history)) + + return "\n".join(lines) diff --git a/app/services/argos/agent.py b/app/services/argos/agent.py new file mode 100644 index 00000000..dffe8cc1 --- /dev/null +++ b/app/services/argos/agent.py @@ -0,0 +1,134 @@ +# 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 . + +""" +CompanionAgent — the global agentic companion. + +Assembles the account map (cheap DB skeleton) + focus into the system prompt, +offers the tool menu, and runs the tool-calling loop via ai_service. The model +decides which tools to call and chains them; every answer is grounded in the +user's own data. Facts-only compliance rules live in companion_agent.yaml. +""" + +import logging + +from app.services.ai import ai_service +from app.services.ai.prompt_service import prompt_service, resolve_model_provider +from app.services.ai.analytics import log_prompt_usage +from app.services.argos.account_map import build_account_map, render_account_map +from app.services.argos.tools import COMPANION_TOOLS, ToolExecutor +from app.utils.time_utils import now_utc + +logger = logging.getLogger(__name__) + +_MAX_HOPS = 5 + + +def _render_focus(focus): + """Human-readable 'current page' block for the prompt. + + Leads with the real page (URL + title) so the agent answers "what is this page?" + from where the user actually is, not by guessing from the account-wide map. + """ + focus = focus or {} + lines = [] + if focus.get('title'): + lines.append(f"Page title: {focus['title']}") + if focus.get('path'): + lines.append(f"URL path: {focus['path']}") + if focus.get('type'): + lines.append(f"Focus type: {focus['type']}") + if focus.get('company_id'): + lines.append(f"Company id: {focus['company_id']}") + if focus.get('project_id'): + lines.append(f"Research project id: {focus['project_id']}") + return "\n".join(lines) if lines else "No page context provided." + + +class CompanionAgent: + """One instance per user; ``ask`` answers a question grounded in their account.""" + + def __init__(self, user_id): + self.user_id = user_id + + def ask(self, question, history=None, focus=None): + """ + Answer a question using account map + tools. + + Returns {'answer': str, 'hops': int, 'tool_calls': [tool names]}. + """ + account_map = build_account_map(self.user_id, focus) + + prompt_data = prompt_service.get_prompt_with_metadata( + 'companion', 'companion_agent', + account_map=render_account_map(account_map), + focus=_render_focus(focus), + ) + metadata = prompt_data.get('metadata', {}) + system = prompt_data['prompt'] + model_enum, provider_enum = resolve_model_provider( + metadata, user_id=self.user_id, prompt_category='companion') + + messages = [ + {'role': m['role'], 'content': m['content']} + for m in (history or [])[-10:] + ] + messages.append({'role': 'user', 'content': question}) + + started = now_utc() + try: + result = ai_service.generate_with_tools( + messages, + COMPANION_TOOLS, + ToolExecutor(self.user_id), + system=system, + provider=provider_enum, + model=model_enum, + max_hops=_MAX_HOPS, + max_tokens=metadata.get('max_tokens', 4096), + temperature=metadata.get('temperature', 0.3), + ) + except Exception as e: + self._log_usage(metadata, provider_enum, model_enum, started, + success=False, error=str(e)) + raise + + self._log_usage(metadata, provider_enum, model_enum, started, + success=True, hops=result.hops) + return { + 'answer': result.text, + 'hops': result.hops, + 'tool_calls': [c.name for c in result.calls], + } + + def _log_usage(self, metadata, provider_enum, model_enum, started, + success, hops=0, error=None): + """Record the companion run in prompt analytics (best-effort).""" + try: + latency_ms = int((now_utc() - started).total_seconds() * 1000) + log_prompt_usage( + prompt_name='companion_agent', + prompt_version=str(metadata.get('version', '1.0')), + provider=getattr(provider_enum, 'value', str(provider_enum)), + model=getattr(model_enum, 'model_id', str(model_enum)), + latency_ms=latency_ms, + success=success, + error_message=error, + context_data={'hops': hops}, + user_id=self.user_id, + ) + except Exception as log_err: + logger.warning(f"companion usage logging failed: {log_err}") diff --git a/app/services/argos/companion.py b/app/services/argos/companion.py index a5857497..98fde413 100644 --- a/app/services/argos/companion.py +++ b/app/services/argos/companion.py @@ -33,6 +33,7 @@ from app.models.journal import DecisionJournal, PatternRecognition from app.models.research import ResearchProject, FreeResearchQuestion from app.models import Company +from app.models.portfolio import PortfolioPosition from app.services.ai import ai_service from app.services.ai.prompt_service import prompt_service, resolve_model_provider @@ -61,6 +62,26 @@ def to_dict(self) -> Dict[str, Any]: return asdict(self) +@dataclass +class CompanyContext: + """Project-free context about one company. Used on the company dashboard.""" + company_id: int + company_name: str + sector_name: str + project_state: str + red_flags: str + green_flags: str + investment_thesis: str + latest_decision: Optional[str] # decision from the most recent completed project + position: Optional[Dict[str, Any]] # held-position summary, or None + journal_summary: str + mistake_summary: str + pattern_summary: str + + def to_summary(self) -> Dict[str, Any]: + return asdict(self) + + class CompanionMixin: """ Companion features mixed into ArgosService. @@ -72,6 +93,83 @@ class CompanionMixin: # Companion Features # ========================================================================= + def build_company_context(self, company_id: int) -> "CompanyContext": + """ + Assemble project-optional context about one company (for the company dashboard). + + "Project-free" means it works WITHOUT a research project — not that it ignores + them. When projects exist it distinguishes active (in-progress) from completed + (decided) ones: live flags/thesis come from the active project if there is one, + otherwise from the most recent completed project, whose decision is surfaced too. + Reuses the journal/mistake/pattern enrichment helpers. + """ + company = Company.query.filter_by(id=company_id, user_id=self.user_id).first() + if not company: + raise ValueError(f"Company {company_id} not found or access denied") + + sector_name = 'Unknown' + if hasattr(company, 'sector') and company.sector: + sector_name = company.sector.name if hasattr(company.sector, 'name') else str(company.sector) + + projects = ( + ResearchProject.query + .filter_by(user_id=self.user_id, company_id=company_id) + .order_by(ResearchProject.id.desc()) + .all() + ) + active = [p for p in projects if (p.status or 'active') != 'completed'] + completed = [p for p in projects if (p.status or 'active') == 'completed'] + + # Live flags/thesis: prefer an active project, else the latest completed. + primary = active[0] if active else (completed[0] if completed else None) + if primary: + red_flags = ', '.join(primary.red_flags or []) or 'None identified yet' + green_flags = ', '.join(primary.green_flags or []) or 'None identified yet' + investment_thesis = primary.investment_thesis or 'Not yet formed' + else: + red_flags = green_flags = 'None identified yet' + investment_thesis = 'Not yet formed' + + # Human-readable state across all projects. + state_parts = [] + if active: + a = active[0] + step = f" (step {a.current_step_index + 1})" if a.current_step_index is not None else '' + state_parts.append(f"{len(active)} active{step}") + if completed: + decisions = [p.decision for p in completed if p.decision] + dec = f" — decisions: {', '.join(decisions)}" if decisions else '' + state_parts.append(f"{len(completed)} completed{dec}") + project_state = '; '.join(state_parts) or 'No research project yet' + + latest_decision = next((p.decision for p in completed if p.decision), None) + + # Held position summary (or None). + pos = PortfolioPosition.query.filter_by( + user_id=self.user_id, company_id=company_id, is_active=True).first() + position = None + if pos: + position = { + 'unrealized_pct': float(pos.unrealized_gain_loss_pct or 0), + 'days_held': pos.days_held or 0, + 'current_value': float(pos.current_value or 0), + } + + return CompanyContext( + company_id=company_id, + company_name=company.name, + sector_name=sector_name, + project_state=project_state, + red_flags=red_flags, + green_flags=green_flags, + investment_thesis=investment_thesis, + latest_decision=latest_decision, + position=position, + journal_summary=self._build_journal_summary(company_id, sector_name), + mistake_summary=self._build_mistake_summary(company_id, sector_name), + pattern_summary=self._build_pattern_summary(), + ) + def build_research_context(self, project_id: int, step_index: Optional[int] = None) -> CompanionContext: """ Build enriched context from ResearchProject + history data. @@ -249,8 +347,8 @@ def get_warnings_by_company(self, company_id: int) -> List[Dict[str, Any]]: Get proactive warnings for a company — pattern warnings + journal insights + mistake history. Pure DB queries, zero token cost. Works with company_id directly (no project required). """ - company = Company.query.get(company_id) - if not company or company.user_id != self.user_id: + company = Company.query.filter_by(id=company_id, user_id=self.user_id).first() + if not company: return [] warnings = [] diff --git a/app/services/argos/knowledge_index.py b/app/services/argos/knowledge_index.py new file mode 100644 index 00000000..40e2cd93 --- /dev/null +++ b/app/services/argos/knowledge_index.py @@ -0,0 +1,176 @@ +# 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 . + +""" +Knowledge index builder for the companion. + +Indexes the four knowledge sources (research findings, journal entries, decision +journals, saved resources) into ``KnowledgeChunk`` rows: each item is summarised +to a short factual string, embedded once (BGE-base, 768 dims), and upserted keyed +by ``(source_type, source_id)`` so re-running is idempotent. + +Long items are summarised via a tunable YAML prompt (``companion/knowledge_summary``); +short items pass through unchanged to avoid a needless LLM call. +""" + +import logging + +from app import db +from app.models.knowledge_chunk import KnowledgeChunk +from app.models.research import ResearchProject +from app.models.journal import JournalEntry, DecisionJournal +from app.models.company import Company, CompanyResource +from app.models.idea_pipeline import MistakeLog +from app.services.ai import ai_service +from app.services.ai.embedding_service import get_embedding_service +from app.services.ai.prompt_service import prompt_service, resolve_model_provider +from app.utils.db_utils import safe_commit + +logger = logging.getLogger(__name__) + +_MAX_WORDS = 60 + + +def _summarise(user_id, source_type, raw): + """Short text passes through; long text is summarised via YAML prompt.""" + raw = (raw or '').strip() + if not raw: + return '' + if len(raw.split()) <= _MAX_WORDS: + return raw + try: + prompt_data = prompt_service.get_prompt_with_metadata( + 'companion', 'knowledge_summary', + source_type=source_type, max_words=_MAX_WORDS, content=raw[:4000]) + model_enum, provider_enum = resolve_model_provider( + prompt_data.get('metadata', {}), user_id=user_id, prompt_category='companion') + summary = ai_service.generate_text( + prompt_data['prompt'], model=model_enum, provider=provider_enum, + max_tokens=120, temperature=0.2) + return (summary or '').strip() or raw[:400] + except Exception as e: + logger.warning(f"knowledge summary failed ({source_type}): {e}") + return raw[:400] + + +def _mistake_text(mistake): + """Flatten a MistakeLog into one embeddable string (what + lesson).""" + parts = [mistake.title or '', mistake.description or ''] + if mistake.lesson_learned: + parts.append(f"Lesson: {mistake.lesson_learned}") + return '. '.join(p for p in parts if p) + + +def _upsert(user_id, company_id, source_type, source_id, title, raw): + """Create or update one KnowledgeChunk. Returns the row, or None if empty.""" + summary = _summarise(user_id, source_type, raw) + if not summary: + return None + + embedding = get_embedding_service().embed(summary) + + # Always scope the lookup by user_id (defence-in-depth; source ids are + # globally-unique PKs so cross-user collision can't happen, but we never + # query without user_id). + row = KnowledgeChunk.query.filter_by( + user_id=user_id, source_type=source_type, source_id=source_id).first() + if row is None: + row = KnowledgeChunk(source_type=source_type, source_id=source_id) + db.session.add(row) + + row.user_id = user_id + row.company_id = company_id + row.title = (title or '')[:300] + row.summary = summary + row.embedding = embedding.tolist() if embedding is not None else None + row.token_estimate = max(1, len(summary) // 4) + return row + + +def index_company_knowledge(user_id, company_id): + """(Re)index all knowledge for one company. Returns the number of chunks.""" + count = 0 + + # Research findings (one chunk per finding; stable id from project + position) + projects = ResearchProject.query.filter_by( + user_id=user_id, company_id=company_id).all() + for proj in projects: + for i, finding in enumerate(proj.key_findings or []): + if _upsert(user_id, company_id, 'finding', + proj.id * 1000 + i, 'Finding', str(finding)): + count += 1 + + # Journal entries (free-form notes) + for entry in JournalEntry.query.filter_by( + user_id=user_id, company_id=company_id).all(): + if _upsert(user_id, company_id, 'journal', entry.id, entry.title, entry.content): + count += 1 + + # Decision journals (structured theses) + for decision in DecisionJournal.query.filter_by( + user_id=user_id, company_id=company_id).all(): + if _upsert(user_id, company_id, 'decision', decision.id, + 'Decision', decision.investment_thesis): + count += 1 + + # Saved resources (links + files: index title/description) + for resource in CompanyResource.query.filter_by( + user_id=user_id, company_id=company_id).all(): + raw = resource.description or resource.title + if _upsert(user_id, company_id, 'resource', resource.id, resource.title, raw): + count += 1 + + # Logged mistakes tied to this company (blind-spot detector signal) + for mistake in MistakeLog.query.filter_by( + user_id=user_id, company_id=company_id).all(): + if _upsert(user_id, company_id, 'mistake', mistake.id, + mistake.title, _mistake_text(mistake)): + count += 1 + + safe_commit(db.session, 'index company knowledge') + return count + + +def index_general_knowledge(user_id): + """(Re)index the user's knowledge that isn't tied to any company. + + General notes and general mistakes carry ``company_id IS NULL`` and would be + missed by the per-company pass, so they get their own path. + """ + count = 0 + + for entry in JournalEntry.query.filter_by( + user_id=user_id, company_id=None).all(): + if _upsert(user_id, None, 'journal', entry.id, entry.title, entry.content): + count += 1 + + for mistake in MistakeLog.query.filter_by( + user_id=user_id, company_id=None).all(): + if _upsert(user_id, None, 'mistake', mistake.id, + mistake.title, _mistake_text(mistake)): + count += 1 + + safe_commit(db.session, 'index general knowledge') + return count + + +def index_user_knowledge(user_id): + """(Re)index all of a user's knowledge — every company plus general items.""" + total = 0 + for company in Company.query.filter_by(user_id=user_id).all(): + total += index_company_knowledge(user_id, company.id) + total += index_general_knowledge(user_id) + return total diff --git a/app/services/argos/knowledge_search.py b/app/services/argos/knowledge_search.py new file mode 100644 index 00000000..2f8b7d70 --- /dev/null +++ b/app/services/argos/knowledge_search.py @@ -0,0 +1,112 @@ +# 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 . + +""" +Budgeted knowledge retrieval — "the control". + +`search_my_knowledge` embeds the query, cosine-ranks the user's KnowledgeChunks, +and fills the result under a HARD budget: a total ceiling AND per-source caps, so +unbounded sources (notes, links) can never crowd out research findings. Chunks +from currently-held companies get a small ranking bonus (the portfolio reflects +where the user's real knowledge concentrates). + +Only summaries are returned here. Raw note/resource text is fetched separately via +`get_resource`, and only for the owning user. +""" + +import numpy as np + +from app.models.knowledge_chunk import KnowledgeChunk +from app.models.portfolio import PortfolioPosition +from app.models.journal import JournalEntry +from app.models.company import CompanyResource +from app.services.ai.embedding_service import get_embedding_service + +_DEFAULT_CAPS = {'finding': 3, 'journal': 3, 'resource': 2, 'decision': 3, 'mistake': 2} +_HELD_BONUS = 0.05 + + +def _held_company_ids(user_id): + return { + p.company_id + for p in PortfolioPosition.query.filter_by(user_id=user_id, is_active=True).all() + } + + +def search_my_knowledge(user_id, query, company_id=None, total_cap=8, per_source_caps=None): + """ + Retrieve the user's knowledge for a query, under a total + per-source budget. + + Returns a list of dicts: {source_type, source_id, title, summary, score}, + at most `total_cap` items, with each source capped by `per_source_caps`. + """ + caps = per_source_caps or dict(_DEFAULT_CAPS) + + query_vec = get_embedding_service().embed(query) + if query_vec is None: + return [] + query_vec = np.asarray(query_vec, dtype=np.float64) + query_norm = np.linalg.norm(query_vec) or 1.0 + + rows = KnowledgeChunk.query.filter_by(user_id=user_id) + if company_id is not None: + rows = rows.filter_by(company_id=company_id) + + held = _held_company_ids(user_id) + + scored = [] + for row in rows.all(): + if row.embedding is None: + continue + vec = np.asarray(row.embedding, dtype=np.float64) + denom = query_norm * (np.linalg.norm(vec) or 1.0) + score = float(np.dot(query_vec, vec) / denom) + if row.company_id in held: + score += _HELD_BONUS + scored.append((score, row)) + + scored.sort(key=lambda pair: pair[0], reverse=True) + + results = [] + used = {} + for score, row in scored: + if len(results) >= total_cap: + break + if used.get(row.source_type, 0) >= caps.get(row.source_type, 1): + continue + used[row.source_type] = used.get(row.source_type, 0) + 1 + results.append({ + 'source_type': row.source_type, + 'source_id': row.source_id, + 'title': row.title, + 'summary': row.summary, + 'score': round(score, 4), + }) + return results + + +def get_resource(user_id, source_type, source_id): + """Fetch raw content for one note/resource — only if owned by the user.""" + if source_type == 'journal': + entry = JournalEntry.query.filter_by(id=source_id, user_id=user_id).first() + return {'title': entry.title, 'content': entry.content} if entry else None + if source_type == 'resource': + resource = CompanyResource.query.filter_by(id=source_id, user_id=user_id).first() + if not resource: + return None + return {'title': resource.title, 'url': resource.url, + 'description': resource.description} + return None diff --git a/app/services/argos/tools.py b/app/services/argos/tools.py new file mode 100644 index 00000000..68d6b737 --- /dev/null +++ b/app/services/argos/tools.py @@ -0,0 +1,166 @@ +# 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 . + +""" +Companion tools + the ownership-bound ToolExecutor. + +`COMPANION_TOOLS` are the schemas the model sees (names, descriptions, JSON-Schema +params). `ToolExecutor` is the SECURITY BOUNDARY: it binds `current_user.id` at +construction and validates ownership of every company/project/resource id before +touching a service. The model never supplies a user id and cannot reach another +user's data — a bad id returns an error string, never a leak. + +Each tool is a thin wrapper over a service we already have; results are returned +as compact JSON (dates/Decimals stringified) for the model to read. +""" + +import json +import logging +import dataclasses + +from app.services.ai.tool_calling import ToolSpec, ToolResult +from app.services.argos.core import ArgosService +from app.services.argos.knowledge_search import search_my_knowledge, get_resource +from app.services.portfolio_intelligence import PortfolioIntelligenceService +from app.models.company import Company +from app.models.research import ResearchProject +from app.models.journal import PatternRecognition +from app.models.idea_pipeline import MistakeLog + +logger = logging.getLogger(__name__) + + +COMPANION_TOOLS = [ + ToolSpec( + 'get_portfolio_overview', + "The user's portfolio: positions, weights, concentration, and how each " + "holding's actual return compares to its original thesis.", + {'type': 'object', 'properties': {}}, + ), + ToolSpec( + 'get_company_context', + "Everything the user knows about one company: research state, flags, thesis, " + "past decision, held position, and their journal/mistake/pattern history for it.", + {'type': 'object', + 'properties': {'company_id': {'type': 'integer'}}, + 'required': ['company_id']}, + ), + ToolSpec( + 'get_research_project', + "Findings, questions, flags, and thesis for one research project.", + {'type': 'object', + 'properties': {'project_id': {'type': 'integer'}}, + 'required': ['project_id']}, + ), + ToolSpec( + 'search_my_knowledge', + "Semantic search across the user's own research findings, journal notes, " + "saved links, decisions, and logged mistakes. Use for 'what did I find/note " + "about X' questions. Optionally scope to one company.", + {'type': 'object', + 'properties': {'query': {'type': 'string'}, + 'company_id': {'type': 'integer'}}, + 'required': ['query']}, + ), + ToolSpec( + 'get_resource', + "Fetch the raw text of one note or saved resource returned by " + "search_my_knowledge (source_type is 'journal' or 'resource').", + {'type': 'object', + 'properties': {'source_type': {'type': 'string'}, + 'source_id': {'type': 'integer'}}, + 'required': ['source_type', 'source_id']}, + ), + ToolSpec( + 'get_mistakes_and_patterns', + "The user's past mistakes and behavioural patterns, as facts to weigh against " + "the current decision.", + {'type': 'object', + 'properties': {'topic': {'type': 'string'}}}, + ), +] + + +class ToolExecutor: + """Dispatches a ToolCall to its handler, scoped to one user.""" + + def __init__(self, user_id): + self.user_id = user_id + + def __call__(self, call): + handler = getattr(self, f'_{call.name}', None) + if handler is None: + return ToolResult(call.id, f"Unknown tool: {call.name}") + try: + data = handler(call.arguments or {}) + return ToolResult(call.id, json.dumps(data, default=str)) + except Exception as e: + logger.warning(f"companion tool {call.name} failed: {e}") + return ToolResult(call.id, json.dumps({'error': f'Tool failed: {e}'})) + + # --- handlers (each ownership-scoped to self.user_id) ---------------- + + def _get_company_context(self, args): + company = Company.query.filter_by( + id=args.get('company_id'), user_id=self.user_id).first() + if not company: + return {'error': 'Company not found or access denied'} + return ArgosService(self.user_id).build_company_context(company.id).to_summary() + + def _get_research_project(self, args): + project = ResearchProject.query.filter_by( + id=args.get('project_id'), user_id=self.user_id).first() + if not project: + return {'error': 'Project not found or access denied'} + return ArgosService(self.user_id).build_research_context(project.id).to_dict() + + def _get_portfolio_overview(self, args): + service = PortfolioIntelligenceService(self.user_id) + reality = service.get_thesis_reality_check() + return { + 'positions': [ + dataclasses.asdict(t) if dataclasses.is_dataclass(t) else dict(t.__dict__) + for t in reality[:20] + ], + } + + def _search_my_knowledge(self, args): + return {'results': search_my_knowledge( + self.user_id, args['query'], args.get('company_id'))} + + def _get_resource(self, args): + resource = get_resource(self.user_id, args['source_type'], args['source_id']) + return resource or {'error': 'Not found or access denied'} + + def _get_mistakes_and_patterns(self, args): + # User-wide mistakes + behavioural patterns (not company-scoped). + mistakes = (MistakeLog.query + .filter_by(user_id=self.user_id) + .order_by(MistakeLog.id.desc()).limit(8).all()) + patterns = (PatternRecognition.query + .filter_by(user_id=self.user_id) + .order_by(PatternRecognition.impact_score.desc()).limit(5).all()) + return { + 'mistakes': [ + {'title': m.title, 'type': m.mistake_type, 'lesson': m.lesson_learned} + for m in mistakes + ], + 'patterns': [ + {'name': p.pattern_name, 'impact': p.impact_score, + 'how_to_avoid': p.how_to_avoid} + for p in patterns + ], + } diff --git a/app/services/background_tasks.py b/app/services/background_tasks.py index fbadcf74..81e07a35 100644 --- a/app/services/background_tasks.py +++ b/app/services/background_tasks.py @@ -35,6 +35,7 @@ from app.celery_tasks import checklist_item_analyze_task from app.celery_tasks import ai_research_assist_task from app.celery_tasks import screening_analysis_task +from app.celery_tasks import companion_ask_task logger = logging.getLogger(__name__) @@ -293,6 +294,24 @@ def start_bias_check(user_id, project_id): return task_id + @staticmethod + def start_companion_ask(user_id, question, history, focus): + """Start a companion agentic-chat task in the background. Returns task_id.""" + task_id = str(uuid.uuid4()) + task = BackgroundTask( + id=task_id, + user_id=user_id, + task_type='companion_ask', + status='pending', + ) + db.session.add(task) + db.session.commit() + + celery_task = companion_ask_task.delay(task_id, user_id, question, history, focus) + logger.info(f"Started companion_ask task {task_id} (Celery: {celery_task.id}) " + f"for user {user_id}") + return task_id + @staticmethod def start_argos_deep_analysis(user_id, company_id, step_type, step_context, current_text, include_companion_warnings): """Start an Argos deep analysis task in the background. diff --git a/app/static/css/modules/_companion.css b/app/static/css/modules/_companion.css index 4d1205ec..39d3a197 100644 --- a/app/static/css/modules/_companion.css +++ b/app/static/css/modules/_companion.css @@ -560,6 +560,13 @@ flex-shrink: 0; } +.companion-panel-heading { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + .companion-panel-title { display: flex; align-items: center; @@ -568,6 +575,21 @@ font-weight: 600; } +.companion-panel-scope { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.7rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.72); + padding-left: calc(7px + 0.5rem); /* align under the title text, past the status dot */ +} + +.companion-panel-scope i { + font-size: 0.75rem; + opacity: 0.9; +} + .companion-status-dot { width: 7px; height: 7px; @@ -640,6 +662,44 @@ background: var(--gray-100, #f3f4f6); color: var(--gray-800, #1f2937); border-bottom-left-radius: var(--radius-sm, 4px); + /* Assistant messages hold rendered markdown (block HTML), not raw text. */ + white-space: normal; +} + +/* Rendered-markdown spacing inside assistant bubbles */ +.c-msg--assistant p { + margin: 0 0 0.5rem; +} + +.c-msg--assistant p:last-child { + margin-bottom: 0; +} + +.c-msg--assistant ul, +.c-msg--assistant ol { + margin: 0.25rem 0 0.5rem; + padding-left: 1.2rem; +} + +.c-msg--assistant li { + margin-bottom: 0.15rem; +} + +.c-msg--assistant li:last-child { + margin-bottom: 0; +} + +.c-msg--assistant pre { + white-space: pre-wrap; + word-break: break-word; + background: var(--gray-200, #e5e7eb); + padding: 0.4rem 0.55rem; + border-radius: var(--radius-sm, 4px); + margin: 0.3rem 0; +} + +.c-msg--assistant code { + font-size: 0.92em; } .c-msg--system { diff --git a/app/static/js/companion.js b/app/static/js/companion.js new file mode 100644 index 00000000..dc521400 --- /dev/null +++ b/app/static/js/companion.js @@ -0,0 +1,454 @@ +/** + * ============================================================================= + * Research Companion — Floating Chat Widget (extracted from _companion_widget.html) + * ============================================================================= + * Config is read from the #companion-root dataset so the widget can be mounted + * on any page. Focus is a page hint: {type, company_id?, project_id?, step?}. + * data-endpoint-base — global companion API base (default: /companion) + * data-focus-type — 'company' | 'research' | 'portfolio' | '' + * data-focus-company-id — company id (or empty) + * data-focus-project-id — research project id (or empty) + * data-focus-step — research step index (or empty) + * + * Chat + capture go to the global /companion endpoint; wrap-up stays on the + * research route (research-only feature). History is one rolling thread per + * browser tab, kept in sessionStorage. + * + * Exposes window.CompanionChat with toggle/send/quickAction/saveCapture/runWrapup. + */ +(function () { + const root = document.getElementById('companion-root'); + if (!root) return; + + const toInt = (v) => (v ? parseInt(v, 10) : null); + + // Thread identity: one conversation per focus context. Most-specific wins, so a + // research project (which belongs to a company) keeps its own thread, distinct + // from the company page and the portfolio. Unfocused pages share 'general'. + const focusKey = (f) => { + if (f.project_id) return 'project:' + f.project_id; + if (f.company_id) return 'company:' + f.company_id; + if (f.type === 'portfolio') return 'portfolio'; + return 'general'; + }; + + // Human label for the current scope, shown in the panel header so a shared + // 'general' thread across unfocused pages reads as intentional, not a glitch. + // Generic (scope type only) — no per-page wiring. + const scopeLabel = (f) => { + if (f.project_id) return { icon: 'bi-search', text: 'This research session' }; + if (f.company_id) return { icon: 'bi-building', text: 'Focused on this company' }; + if (f.type === 'portfolio') return { icon: 'bi-pie-chart', text: 'Across your portfolio' }; + return { icon: 'bi-globe', text: 'Across your whole account' }; + }; + + const cfg = { + endpointBase: root.dataset.endpointBase || '/companion', + researchBase: '/research/workflow/companion', + focus: { + type: root.dataset.focusType || '', + company_id: toInt(root.dataset.focusCompanyId), + project_id: toInt(root.dataset.focusProjectId), + step: toInt(root.dataset.focusStep), + // Which page the user is actually on, so the agent can answer "what is this + // page?" instead of guessing from the account-wide map. + path: window.location.pathname, + title: document.title, + }, + }; + + // sessionStorage keys are scoped to the focus context (and per browser tab), so + // switching pages loads the right conversation and tabs never collide. + const CTX_KEY = focusKey(cfg.focus); + const THREAD_KEY = 'companion.thread:' + CTX_KEY; + const OPEN_KEY = 'companion.open'; // tab-global: whether the panel is open + const PENDING_KEY = 'companion.pending:' + CTX_KEY; // in-flight task for this context + + // Quick actions per focus type. `prefill` sends a question through the agent; + // `action` runs a widget function. "Capture" is universal. Labels stay factual + // (the companion surfaces facts, not opinions — e.g. concentration, not "risk"). + const QUICK_ACTIONS = { + company: [ + { label: 'What did I miss?', icon: 'bi-search', + prefill: 'What did I miss on this company — which research steps, flags, or checkpoints are still open?' }, + { label: 'Past mistakes here?', icon: 'bi-exclamation-triangle', + prefill: 'What past mistakes or behavioural patterns of mine are relevant to this company?' }, + { label: 'Capture', icon: 'bi-bookmark-plus', action: 'capture' }, + ], + portfolio: [ + { label: 'Where am I concentrated?', icon: 'bi-pie-chart', + prefill: 'Where is my portfolio most concentrated — by position and by sector?' }, + { label: 'Checkpoints due?', icon: 'bi-calendar-check', + prefill: 'Which of my holdings have checkpoints or thesis reviews due?' }, + { label: 'Capture', icon: 'bi-bookmark-plus', action: 'capture' }, + ], + research: [ + { label: 'Gaps?', icon: 'bi-search', + prefill: 'What gaps remain in my research for this step?' }, + { label: 'Wrap Up', icon: 'bi-flag', action: 'wrapup' }, + { label: 'Capture', icon: 'bi-bookmark-plus', action: 'capture' }, + ], + default: [ + { label: 'Capture', icon: 'bi-bookmark-plus', action: 'capture' }, + ], + }; + + const CompanionChat = { + endpointBase: cfg.endpointBase, + researchBase: cfg.researchBase, + focus: cfg.focus, + projectId: cfg.focus.project_id, // research-only helpers (wrap-up) + stepIndex: cfg.focus.step || 0, + isOpen: false, + conversationHistory: [], + + // One rolling thread per tab. + loadThread() { + try { + this.conversationHistory = JSON.parse(sessionStorage.getItem(THREAD_KEY) || '[]'); + } catch (e) { + this.conversationHistory = []; + } + }, + saveThread() { + try { + sessionStorage.setItem(THREAD_KEY, JSON.stringify(this.conversationHistory)); + } catch (e) { /* storage full / disabled — non-fatal */ } + }, + + // Apply + persist open/closed. Shared by toggle() and the on-load restore so + // the panel stays open across navigations (tab-global preference). + setOpen(open) { + this.isOpen = open; + document.getElementById('companionPanel').classList.toggle('open', open); + document.getElementById('companionFab').classList.toggle('active', open); + document.getElementById('companionFabIcon').className = + open ? 'bi bi-x-lg' : 'bi bi-chat-text'; + try { + sessionStorage.setItem(OPEN_KEY, open ? '1' : ''); + } catch (e) { /* storage disabled — non-fatal */ } + if (open) { + document.getElementById('companionFabBadge').style.display = 'none'; + this.scrollToBottom(); + } + }, + + toggle() { + this.setOpen(!this.isOpen); + if (this.isOpen) document.getElementById('companionChatInput').focus(); + }, + + async send() { + const input = document.getElementById('companionChatInput'); + const text = input.value.trim(); + if (!text) return; + + // Check GDPR consent before sending data to AI providers + if (typeof checkAIConsent === 'function') { + const consented = await checkAIConsent(); + if (!consented) return; + } + + // Add user message + this.appendMessage('user', text); + this.conversationHistory.push({ role: 'user', content: text }); + this.saveThread(); + input.value = ''; + + // Show typing indicator + this.showTyping(); + + try { + // Kick off the background task, then poll for the answer. + const response = await fetch(`${this.endpointBase}/ask`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + question: text, + history: this.conversationHistory, + focus: this.focus + }) + }); + const data = await response.json(); + if (!data.success) { + this.hideTyping(); + this.appendMessage('assistant', `Error: ${this.escapeHtml(data.error || 'Failed to start')}`); + return; + } + // Record the in-flight task so the answer can be recovered if the user + // navigates away before it finishes (the Celery task keeps running). + try { + sessionStorage.setItem(PENDING_KEY, JSON.stringify({ + taskId: data.data.task_id, startedAt: Date.now(), + })); + } catch (e) { /* storage disabled — resume is best-effort */ } + + const answer = await this.pollAnswer(data.data.task_id); + sessionStorage.removeItem(PENDING_KEY); + this.hideTyping(); + if (answer !== null) { + this.appendMessage('assistant', this.renderMarkdown(answer)); + this.conversationHistory.push({ role: 'assistant', content: answer }); + this.saveThread(); + } + } catch (err) { + this.hideTyping(); + this.appendMessage('assistant', `Connection error: ${this.escapeHtml(err.message)}`); + } + }, + + // Poll the companion task until completed/failed. Returns the answer or null. + async pollAnswer(taskId, intervalMs = 1200, timeoutMs = 90000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, intervalMs)); + let data; + try { + const resp = await fetch(`${this.endpointBase}/ask/status/${taskId}`); + data = await resp.json(); + } catch (e) { + continue; // transient — keep polling + } + const status = data && data.data ? data.data.status : null; + if (status === 'completed') { + return (data.data.result && data.data.result.answer) || '(no answer)'; + } + if (status === 'failed') { + this.appendMessage('assistant', `Failed: ${this.escapeHtml((data.data && data.data.error) || 'unknown error')}`); + return null; + } + } + this.appendMessage('assistant', 'Timed out waiting for the companion.'); + return null; + }, + + // Re-attach to a task that was still running when the user navigated here. + // Only this context's pending task is resumed; a task started elsewhere stays + // put and resolves when the user returns to that context. + resumePending() { + let p; + try { p = JSON.parse(sessionStorage.getItem(PENDING_KEY) || 'null'); } + catch (e) { p = null; } + if (!p || !p.taskId) return; + + // Always allow one status check (a finished task returns instantly), but + // bound the polling loop to ~3 min from when it started so a lost worker + // can't spin forever. + const AGE_CAP = 3 * 60 * 1000; + const remaining = Math.max(2000, AGE_CAP - (Date.now() - (p.startedAt || 0))); + + this.showTyping(); + this.pollAnswer(p.taskId, 1200, remaining).then((answer) => { + this.hideTyping(); + sessionStorage.removeItem(PENDING_KEY); + if (answer !== null) { + this.appendMessage('assistant', this.renderMarkdown(answer)); + this.conversationHistory.push({ role: 'assistant', content: answer }); + this.saveThread(); + if (!this.isOpen) { + document.getElementById('companionFabBadge').style.display = 'block'; + } + } + }); + }, + + renderScope() { + const el = document.getElementById('companionScope'); + if (!el) return; + const s = scopeLabel(this.focus); + el.innerHTML = ` ${this.escapeHtml(s.text)}`; + }, + + renderQuickActions() { + const container = document.getElementById('companionQuickActions'); + if (!container) return; + const actions = QUICK_ACTIONS[this.focus.type] || QUICK_ACTIONS.default; + container.innerHTML = ''; + actions.forEach((a) => { + const btn = document.createElement('button'); + btn.className = 'cqa-btn'; + btn.type = 'button'; + btn.innerHTML = ` ${a.label}`; + btn.addEventListener('click', () => { + if (a.action === 'capture') { + this.openCaptureModal(); + } else if (a.action === 'wrapup') { + this.runWrapup(); + } else if (a.prefill) { + document.getElementById('companionChatInput').value = a.prefill; + this.send(); + } + }); + container.appendChild(btn); + }); + }, + + openCaptureModal() { + const modal = new bootstrap.Modal(document.getElementById('companionCaptureModal')); + document.getElementById('captureText').value = ''; + document.getElementById('captureSourceTitle').value = ''; + document.getElementById('captureUrl').value = ''; + modal.show(); + }, + + async saveCapture() { + const text = document.getElementById('captureText').value.trim(); + const sourceTitle = document.getElementById('captureSourceTitle').value.trim(); + const url = document.getElementById('captureUrl').value.trim(); + + if (!text) { + alert('Please enter text to capture'); + return; + } + + try { + const response = await fetch(`${this.endpointBase}/capture`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: text, + source_title: sourceTitle, + url: url, + focus: this.focus + }) + }); + + const data = await response.json(); + + if (data.success) { + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('companionCaptureModal')); + modal.hide(); + + // Show success in chat + this.appendMessage('system', + ` Captured to journal (entry #${data.data.entry_id})` + ); + } else { + alert('Failed to save capture: ' + (data.error || 'Unknown error')); + } + } catch (err) { + alert('Connection error: ' + err.message); + } + }, + + async runWrapup() { + // Wrap-up is a research-only feature; keep it on the research route. + if (!this.projectId) return; + this.appendMessage('system', 'Generating session wrap-up...'); + this.showTyping(); + + try { + const response = await fetch(`${this.researchBase}/${this.projectId}/wrapup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + step_index: this.stepIndex, + session_findings: this.gatherPageText(), + duration_minutes: 0 + }) + }); + + this.hideTyping(); + const data = await response.json(); + + if (data.success) { + this.appendMessage('assistant', + `
Session Summary
${this.renderMarkdown(data.data.summary)}` + ); + } else { + this.appendMessage('assistant', `Wrap-up failed: ${this.escapeHtml(data.error || 'Unknown error')}`); + } + } catch (err) { + this.hideTyping(); + this.appendMessage('assistant', `Connection error: ${this.escapeHtml(err.message)}`); + } + }, + + appendMessage(type, html) { + const msg = document.createElement('div'); + msg.className = `c-msg c-msg--${type}`; + if (type === 'assistant') { + html += aiDisclaimer(true); + } + msg.innerHTML = html; + document.getElementById('companionMessages').appendChild(msg); + this.scrollToBottom(); + }, + + showTyping() { + const msg = document.createElement('div'); + msg.className = 'c-msg c-msg--typing'; + msg.id = 'companionTyping'; + msg.innerHTML = '
'; + document.getElementById('companionMessages').appendChild(msg); + this.scrollToBottom(); + }, + + hideTyping() { + const el = document.getElementById('companionTyping'); + if (el) el.remove(); + }, + + scrollToBottom() { + const el = document.getElementById('companionMessages'); + el.scrollTop = el.scrollHeight; + }, + + gatherPageText() { + const sources = []; + document.querySelectorAll('textarea').forEach(ta => { + if (ta.value.trim() && ta.id !== 'companionChatInput') sources.push(ta.value); + }); + document.querySelectorAll('[class*="blocknote"]').forEach(el => { + if (el.textContent.trim()) sources.push(el.textContent); + }); + const notes = document.getElementById('notes'); + if (notes && notes.value) sources.push(notes.value); + return sources.join(' ').substring(0, 500); + }, + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + + // Render the agent's markdown answer to sanitized HTML. `marked` does the + // markdown → HTML; `DOMPurify` strips anything unsafe (answers are LLM output, + // so this must be sanitized). Falls back to escaped plain text if the vendored + // libs somehow didn't load. + renderMarkdown(text) { + const raw = String(text == null ? '' : text); + if (window.marked && window.DOMPurify) { + return window.DOMPurify.sanitize(window.marked.parse(raw, { breaks: true })); + } + return this.escapeHtml(raw); + } + }; + + window.CompanionChat = CompanionChat; + + // Render the scope indicator and focus-appropriate quick actions. + CompanionChat.renderScope(); + CompanionChat.renderQuickActions(); + + // Restore the rolling thread for this tab and replay it into the panel. + CompanionChat.loadThread(); + CompanionChat.conversationHistory.forEach((m) => { + const isUser = m.role === 'user'; + CompanionChat.appendMessage( + isUser ? 'user' : 'assistant', + isUser ? CompanionChat.escapeHtml(m.content) : CompanionChat.renderMarkdown(m.content) + ); + }); + + // Restore panel open/closed state across navigation (tab-global). Don't focus + // the input on restore — that would steal focus/scroll on every page load. + if (sessionStorage.getItem(OPEN_KEY)) { + CompanionChat.setOpen(true); + } + + // Resume an answer that was still generating when we navigated to this page. + CompanionChat.resumePending(); +})(); diff --git a/app/static/js/vendor/marked.min.js b/app/static/js/vendor/marked.min.js new file mode 100644 index 00000000..a91afe79 --- /dev/null +++ b/app/static/js/vendor/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v12.0.2 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:x(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=x(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=t[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,"\n $1");e=x(e.replace(/^ *>[ \t]?/gm,""),"\n");const n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,k=null;this.options.gfm&&(k=/^\[[ xX]\] /.exec(o),k&&(u="[ ] "!==k[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!k,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(!t)return;if(!/[:|]/.test(t[2]))return;const n=d(t[1]),s=t[2].replace(/^\||\| *$/g,"").split("|"),r=t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[],i={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(const e of s)/^ *-+: *$/.test(e)?i.align.push("right"):/^ *:-+: *$/.test(e)?i.align.push("center"):/^ *:-+ *$/.test(e)?i.align.push("left"):i.align.push(null);for(const e of n)i.header.push({text:e,tokens:this.lexer.inline(e)});for(const e of r)i.rows.push(d(e,i.header.length).map((e=>({text:e,tokens:this.lexer.inline(e)}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:c(t[1])}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&/^/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=x(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),b(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(/\s+/g," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return b(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,y=/(?:[*+-]|\d{1,9}[.)])/,$=k(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,y).replace(/blockCode/g,/ {4}/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),z=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,T=/(?!\s*\])(?:\\.|[^\[\]\\])+/,R=k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",T).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_=k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,y).getRegex(),A="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",S=/|$))/,I=k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))","i").replace("comment",S).replace("tag",A).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),E=k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),q={blockquote:k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",E).getRegex(),code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,def:R,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:m,html:I,lheading:$,list:_,newline:/^(?: *(?:\n|$))+/,paragraph:E,table:f,text:/^[^\n]+/},Z=k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),L={...q,table:Z,paragraph:k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",Z).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex()},P={...q,html:k("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",S).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:f,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:k(z).replace("hr",m).replace("heading"," *#{1,6} *[^\n]").replace("lheading",$).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Q=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v=/^( {2,}|\\)\n(?!\s*$)/,B="\\p{P}\\p{S}",C=k(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,B).getRegex(),M=k(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,B).getRegex(),O=k("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,B).getRegex(),D=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,B).getRegex(),j=k(/\\([punct])/,"gu").replace(/punct/g,B).getRegex(),H=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),U=k(S).replace("(?:--\x3e|$)","--\x3e").getRegex(),X=k("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",U).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),F=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,N=k(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",F).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),G=k(/^!?\[(label)\]\[(ref)\]/).replace("label",F).replace("ref",T).getRegex(),J=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",T).getRegex(),K={_backpedal:f,anyPunctuation:j,autolink:H,blockSkip:/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,br:v,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:f,emStrongLDelim:M,emStrongRDelimAst:O,emStrongRDelimUnd:D,escape:Q,link:N,nolink:J,punctuation:C,reflink:G,reflinkSearch:k("reflink|nolink(?!\\()","g").replace("reflink",G).replace("nolink",J).getRegex(),tag:X,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class se{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='
    ",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new se(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new w(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new le;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.hooks[s],i=t[s];le.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ne.lex(e,t??this.defaults)}parser(e,t){return ie.parse(e,t??this.defaults)}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.hooks?i.hooks.processAllTokens(e):e)).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));let s=e(n,i);i.hooks&&(s=i.hooks.processAllTokens(s)),i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); diff --git a/app/static/js/vendor/purify.min.js b/app/static/js/vendor/purify.min.js new file mode 100644 index 00000000..f016fb30 --- /dev/null +++ b/app/static/js/vendor/purify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.1.7 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.1.7/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=b(Array.prototype.forEach),m=b(Array.prototype.pop),p=b(Array.prototype.push),f=b(String.prototype.toLowerCase),d=b(String.prototype.toString),h=b(String.prototype.match),g=b(String.prototype.replace),T=b(String.prototype.indexOf),y=b(String.prototype.trim),E=b(Object.prototype.hasOwnProperty),_=b(RegExp.prototype.test),A=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:f;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function R(e){for(let t=0;t/gm),B=a(/\${[\w\W]*}/gm),W=a(/^data-[\-\w.\u00B7-\uFFFF]/),G=a(/^aria-[\-\w]+$/),Y=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),j=a(/^(?:\w+script|data):/i),X=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),q=a(/^html$/i),$=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var K=Object.freeze({__proto__:null,MUSTACHE_EXPR:H,ERB_EXPR:z,TMPLIT_EXPR:B,DATA_ATTR:W,ARIA_ATTR:G,IS_ALLOWED_URI:Y,IS_SCRIPT_OR_DATA:j,ATTR_WHITESPACE:X,DOCTYPE_NAME:q,CUSTOM_ELEMENT:$});const V=1,Z=3,J=7,Q=8,ee=9,te=function(){return"undefined"==typeof window?null:window};var ne=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:te();const o=e=>t(e);if(o.version="3.1.7",o.removed=[],!n||!n.document||n.document.nodeType!==ee)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:b,Element:R,NodeFilter:H,NamedNodeMap:z=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:B,DOMParser:W,trustedTypes:G}=n,j=R.prototype,X=C(j,"cloneNode"),$=C(j,"remove"),ne=C(j,"nextSibling"),oe=C(j,"childNodes"),re=C(j,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let ie,ae="";const{implementation:le,createNodeIterator:ce,createDocumentFragment:se,getElementsByTagName:ue}=r,{importNode:me}=a;let pe={};o.isSupported="function"==typeof e&&"function"==typeof re&&le&&void 0!==le.createHTMLDocument;const{MUSTACHE_EXPR:fe,ERB_EXPR:de,TMPLIT_EXPR:he,DATA_ATTR:ge,ARIA_ATTR:Te,IS_SCRIPT_OR_DATA:ye,ATTR_WHITESPACE:Ee,CUSTOM_ELEMENT:_e}=K;let{IS_ALLOWED_URI:Ae}=K,Ne=null;const be=S({},[...L,...v,...D,...x,...M]);let Se=null;const Re=S({},[...I,...U,...P,...F]);let we=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Ce=null,Le=null,ve=!0,De=!0,Oe=!1,xe=!0,ke=!1,Me=!0,Ie=!1,Ue=!1,Pe=!1,Fe=!1,He=!1,ze=!1,Be=!0,We=!1,Ge=!0,Ye=!1,je={},Xe=null;const qe=S({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let $e=null;const Ke=S({},["audio","video","img","source","image","track"]);let Ve=null;const Ze=S({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Je="http://www.w3.org/1998/Math/MathML",Qe="http://www.w3.org/2000/svg",et="http://www.w3.org/1999/xhtml";let tt=et,nt=!1,ot=null;const rt=S({},[Je,Qe,et],d);let it=null;const at=["application/xhtml+xml","text/html"];let lt=null,ct=null;const st=r.createElement("form"),ut=function(e){return e instanceof RegExp||e instanceof Function},mt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ct||ct!==e){if(e&&"object"==typeof e||(e={}),e=w(e),it=-1===at.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,lt="application/xhtml+xml"===it?d:f,Ne=E(e,"ALLOWED_TAGS")?S({},e.ALLOWED_TAGS,lt):be,Se=E(e,"ALLOWED_ATTR")?S({},e.ALLOWED_ATTR,lt):Re,ot=E(e,"ALLOWED_NAMESPACES")?S({},e.ALLOWED_NAMESPACES,d):rt,Ve=E(e,"ADD_URI_SAFE_ATTR")?S(w(Ze),e.ADD_URI_SAFE_ATTR,lt):Ze,$e=E(e,"ADD_DATA_URI_TAGS")?S(w(Ke),e.ADD_DATA_URI_TAGS,lt):Ke,Xe=E(e,"FORBID_CONTENTS")?S({},e.FORBID_CONTENTS,lt):qe,Ce=E(e,"FORBID_TAGS")?S({},e.FORBID_TAGS,lt):{},Le=E(e,"FORBID_ATTR")?S({},e.FORBID_ATTR,lt):{},je=!!E(e,"USE_PROFILES")&&e.USE_PROFILES,ve=!1!==e.ALLOW_ARIA_ATTR,De=!1!==e.ALLOW_DATA_ATTR,Oe=e.ALLOW_UNKNOWN_PROTOCOLS||!1,xe=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,ke=e.SAFE_FOR_TEMPLATES||!1,Me=!1!==e.SAFE_FOR_XML,Ie=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,He=e.RETURN_DOM_FRAGMENT||!1,ze=e.RETURN_TRUSTED_TYPE||!1,Pe=e.FORCE_BODY||!1,Be=!1!==e.SANITIZE_DOM,We=e.SANITIZE_NAMED_PROPS||!1,Ge=!1!==e.KEEP_CONTENT,Ye=e.IN_PLACE||!1,Ae=e.ALLOWED_URI_REGEXP||Y,tt=e.NAMESPACE||et,we=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ut(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(we.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ut(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(we.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(we.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),ke&&(De=!1),He&&(Fe=!0),je&&(Ne=S({},M),Se=[],!0===je.html&&(S(Ne,L),S(Se,I)),!0===je.svg&&(S(Ne,v),S(Se,U),S(Se,F)),!0===je.svgFilters&&(S(Ne,D),S(Se,U),S(Se,F)),!0===je.mathMl&&(S(Ne,x),S(Se,P),S(Se,F))),e.ADD_TAGS&&(Ne===be&&(Ne=w(Ne)),S(Ne,e.ADD_TAGS,lt)),e.ADD_ATTR&&(Se===Re&&(Se=w(Se)),S(Se,e.ADD_ATTR,lt)),e.ADD_URI_SAFE_ATTR&&S(Ve,e.ADD_URI_SAFE_ATTR,lt),e.FORBID_CONTENTS&&(Xe===qe&&(Xe=w(Xe)),S(Xe,e.FORBID_CONTENTS,lt)),Ge&&(Ne["#text"]=!0),Ie&&S(Ne,["html","head","body"]),Ne.table&&(S(Ne,["tbody"]),delete Ce.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');ie=e.TRUSTED_TYPES_POLICY,ae=ie.createHTML("")}else void 0===ie&&(ie=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(G,c)),null!==ie&&"string"==typeof ae&&(ae=ie.createHTML(""));i&&i(e),ct=e}},pt=S({},["mi","mo","mn","ms","mtext"]),ft=S({},["annotation-xml"]),dt=S({},["title","style","font","a","script"]),ht=S({},[...v,...D,...O]),gt=S({},[...x,...k]),Tt=function(e){p(o.removed,{element:e});try{re(e).removeChild(e)}catch(t){$(e)}},yt=function(e,t){try{p(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){p(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e&&!Se[e])if(Fe||He)try{Tt(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},Et=function(e){let t=null,n=null;if(Pe)e=""+e;else{const t=h(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===it&&tt===et&&(e=''+e+"");const o=ie?ie.createHTML(e):e;if(tt===et)try{t=(new W).parseFromString(o,it)}catch(e){}if(!t||!t.documentElement){t=le.createDocument(tt,"template",null);try{t.documentElement.innerHTML=nt?ae:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),tt===et?ue.call(t,Ie?"html":"body")[0]:Ie?t.documentElement:i},_t=function(e){return ce.call(e.ownerDocument||e,e,H.SHOW_ELEMENT|H.SHOW_COMMENT|H.SHOW_TEXT|H.SHOW_PROCESSING_INSTRUCTION|H.SHOW_CDATA_SECTION,null)},At=function(e){return e instanceof B&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof z)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof b&&e instanceof b},bt=function(e,t,n){pe[e]&&u(pe[e],(e=>{e.call(o,t,n,ct)}))},St=function(e){let t=null;if(bt("beforeSanitizeElements",e,null),At(e))return Tt(e),!0;const n=lt(e.nodeName);if(bt("uponSanitizeElement",e,{tagName:n,allowedTags:Ne}),e.hasChildNodes()&&!Nt(e.firstElementChild)&&_(/<[/\w]/g,e.innerHTML)&&_(/<[/\w]/g,e.textContent))return Tt(e),!0;if(e.nodeType===J)return Tt(e),!0;if(Me&&e.nodeType===Q&&_(/<[/\w]/g,e.data))return Tt(e),!0;if(!Ne[n]||Ce[n]){if(!Ce[n]&&wt(n)){if(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,n))return!1;if(we.tagNameCheck instanceof Function&&we.tagNameCheck(n))return!1}if(Ge&&!Xe[n]){const t=re(e)||e.parentNode,n=oe(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=X(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,ne(e))}}}return Tt(e),!0}return e instanceof R&&!function(e){let t=re(e);t&&t.tagName||(t={namespaceURI:tt,tagName:"template"});const n=f(e.tagName),o=f(t.tagName);return!!ot[e.namespaceURI]&&(e.namespaceURI===Qe?t.namespaceURI===et?"svg"===n:t.namespaceURI===Je?"svg"===n&&("annotation-xml"===o||pt[o]):Boolean(ht[n]):e.namespaceURI===Je?t.namespaceURI===et?"math"===n:t.namespaceURI===Qe?"math"===n&&ft[o]:Boolean(gt[n]):e.namespaceURI===et?!(t.namespaceURI===Qe&&!ft[o])&&!(t.namespaceURI===Je&&!pt[o])&&!gt[n]&&(dt[n]||!ht[n]):!("application/xhtml+xml"!==it||!ot[e.namespaceURI]))}(e)?(Tt(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!_(/<\/no(script|embed|frames)/i,e.innerHTML)?(ke&&e.nodeType===Z&&(t=e.textContent,u([fe,de,he],(e=>{t=g(t,e," ")})),e.textContent!==t&&(p(o.removed,{element:e.cloneNode()}),e.textContent=t)),bt("afterSanitizeElements",e,null),!1):(Tt(e),!0)},Rt=function(e,t,n){if(Be&&("id"===t||"name"===t)&&(n in r||n in st))return!1;if(De&&!Le[t]&&_(ge,t));else if(ve&&_(Te,t));else if(!Se[t]||Le[t]){if(!(wt(e)&&(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,e)||we.tagNameCheck instanceof Function&&we.tagNameCheck(e))&&(we.attributeNameCheck instanceof RegExp&&_(we.attributeNameCheck,t)||we.attributeNameCheck instanceof Function&&we.attributeNameCheck(t))||"is"===t&&we.allowCustomizedBuiltInElements&&(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,n)||we.tagNameCheck instanceof Function&&we.tagNameCheck(n))))return!1}else if(Ve[t]);else if(_(Ae,g(n,Ee,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==T(n,"data:")||!$e[e]){if(Oe&&!_(ye,g(n,Ee,"")));else if(n)return!1}else;return!0},wt=function(e){return"annotation-xml"!==e&&h(e,_e)},Ct=function(e){bt("beforeSanitizeAttributes",e,null);const{attributes:t}=e;if(!t)return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Se};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=lt(a);let p="value"===a?c:y(c);if(n.attrName=s,n.attrValue=p,n.keepAttr=!0,n.forceKeepAttr=void 0,bt("uponSanitizeAttribute",e,n),p=n.attrValue,n.forceKeepAttr)continue;if(yt(a,e),!n.keepAttr)continue;if(!xe&&_(/\/>/i,p)){yt(a,e);continue}ke&&u([fe,de,he],(e=>{p=g(p,e," ")}));const f=lt(e.nodeName);if(Rt(f,s,p))if(!We||"id"!==s&&"name"!==s||(yt(a,e),p="user-content-"+p),Me&&_(/((--!?|])>)|<\/(style|title)/i,p))yt(a,e);else{if(ie&&"object"==typeof G&&"function"==typeof G.getAttributeType)if(l);else switch(G.getAttributeType(f,s)){case"TrustedHTML":p=ie.createHTML(p);break;case"TrustedScriptURL":p=ie.createScriptURL(p)}try{l?e.setAttributeNS(l,a,p):e.setAttribute(a,p),At(e)?Tt(e):m(o.removed)}catch(e){}}}bt("afterSanitizeAttributes",e,null)},Lt=function e(t){let n=null;const o=_t(t);for(bt("beforeSanitizeShadowDOM",t,null);n=o.nextNode();)bt("uponSanitizeShadowNode",n,null),St(n)||(n.content instanceof s&&e(n.content),Ct(n));bt("afterSanitizeShadowDOM",t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(nt=!e,nt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw A("toString is not a function");if("string"!=typeof(e=e.toString()))throw A("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Ue||mt(t),o.removed=[],"string"==typeof e&&(Ye=!1),Ye){if(e.nodeName){const t=lt(e.nodeName);if(!Ne[t]||Ce[t])throw A("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof b)n=Et("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===V&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!ke&&!Ie&&-1===e.indexOf("<"))return ie&&ze?ie.createHTML(e):e;if(n=Et(e),!n)return Fe?null:ze?ae:""}n&&Pe&&Tt(n.firstChild);const c=_t(Ye?e:n);for(;i=c.nextNode();)St(i)||(i.content instanceof s&&Lt(i.content),Ct(i));if(Ye)return e;if(Fe){if(He)for(l=se.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(Se.shadowroot||Se.shadowrootmode)&&(l=me.call(a,l,!0)),l}let m=Ie?n.outerHTML:n.innerHTML;return Ie&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&_(q,n.ownerDocument.doctype.name)&&(m="\n"+m),ke&&u([fe,de,he],(e=>{m=g(m,e," ")})),ie&&ze?ie.createHTML(m):m},o.setConfig=function(){mt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Ue=!0},o.clearConfig=function(){ct=null,Ue=!1},o.isValidAttribute=function(e,t,n){ct||mt({});const o=lt(e),r=lt(t);return Rt(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&(pe[e]=pe[e]||[],p(pe[e],t))},o.removeHook=function(e){if(pe[e])return m(pe[e])},o.removeHooks=function(e){pe[e]&&(pe[e]=[])},o.removeAllHooks=function(){pe={}},o}();return ne})); +//# sourceMappingURL=purify.min.js.map diff --git a/app/templates/main/_base.html b/app/templates/main/_base.html index fc03c186..fe09e7bd 100644 --- a/app/templates/main/_base.html +++ b/app/templates/main/_base.html @@ -79,6 +79,14 @@ + {# Markdown rendering + sanitizer for AI answers (self-hosted). Loaded before the + companion so marked/DOMPurify are available when companion.js runs. #} + + + + {# Global research/portfolio companion — renders for authenticated users (opt-out) #} + {% include 'main/_companion_widget.html' ignore missing %} + {% block modals %} {% endblock %} diff --git a/app/templates/main/_companion_widget.html b/app/templates/main/_companion_widget.html new file mode 100644 index 00000000..09155488 --- /dev/null +++ b/app/templates/main/_companion_widget.html @@ -0,0 +1,110 @@ +{# + Research Companion — Floating Chat Widget + + A floating action button (FAB) + slide-up chat panel for live companion chat. + Wired to POST /companion//ask for Q&A during research sessions. + + Rendered globally by main/_base.html. A page opts in by setting: + {% set companion_enabled = true %} + {% set companion_focus = {'type': 'research'|'company'|'portfolio', 'company_id': ..., 'project_id': ..., 'step': ...} %} + + CSS: modules/_companion.css +#} + +{% set _focus = companion_focus | default({}) %} + +{% if current_user.is_authenticated and (companion_enabled | default(true)) %} + + + + + + + +
    + +
    +
    +
    + + Research Companion +
    + +
    +
    +
    + +
    +
    + + +
    +
    + Companion ready — facts only, opinions are yours. +
    +
    + + +
    + + +
    + + +
    +
    + + + + + + +{% endif %} diff --git a/celery_app.py b/celery_app.py index 74bc81d8..ce6ec956 100644 --- a/celery_app.py +++ b/celery_app.py @@ -33,6 +33,7 @@ 'app.celery_tasks.tasks_import', # Portfolio import tasks 'app.celery_tasks.tasks_checkpoint_analysis', # Daily checkpoint analysis 'app.celery_tasks.tasks_screening', # Screening analysis tasks + 'app.celery_tasks.tasks_companion', # Companion agentic chat tasks ] ) diff --git a/migrations/versions/c85080497f69_add_knowledge_chunk.py b/migrations/versions/c85080497f69_add_knowledge_chunk.py new file mode 100644 index 00000000..6babfd98 --- /dev/null +++ b/migrations/versions/c85080497f69_add_knowledge_chunk.py @@ -0,0 +1,69 @@ +# 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 . + +"""add knowledge_chunk + +Revision ID: c85080497f69 +Revises: unique_company_ticker +Create Date: 2026-07-24 22:38:15.699396 + +""" +from alembic import op +import sqlalchemy as sa +from pgvector.sqlalchemy import Vector + + +# revision identifiers, used by Alembic. +revision = 'c85080497f69' +down_revision = 'reconcile_schema_drift' +branch_labels = None +depends_on = None + + +def upgrade(): + # Only creates knowledge_chunk. Unrelated autogenerated diffs (dropped + # indexes / constraints on other tables, reflecting pre-existing dev-DB + # drift) were removed by hand — this migration is scoped to the companion + # knowledge index alone. + op.create_table('knowledge_chunk', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=True), + sa.Column('source_type', sa.String(length=20), nullable=False), + sa.Column('source_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=300), nullable=True), + sa.Column('summary', sa.Text(), nullable=False), + sa.Column('embedding', Vector(768), nullable=True), + sa.Column('token_estimate', sa.Integer(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['company_id'], ['company.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('source_type', 'source_id', name='uq_knowledge_source') + ) + with op.batch_alter_table('knowledge_chunk', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_knowledge_chunk_company_id'), ['company_id'], unique=False) + batch_op.create_index(batch_op.f('ix_knowledge_chunk_source_type'), ['source_type'], unique=False) + batch_op.create_index(batch_op.f('ix_knowledge_chunk_user_id'), ['user_id'], unique=False) + + +def downgrade(): + with op.batch_alter_table('knowledge_chunk', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_knowledge_chunk_user_id')) + batch_op.drop_index(batch_op.f('ix_knowledge_chunk_source_type')) + batch_op.drop_index(batch_op.f('ix_knowledge_chunk_company_id')) + + op.drop_table('knowledge_chunk') diff --git a/unittests/conftest.py b/unittests/conftest.py new file mode 100644 index 00000000..32e6cc24 --- /dev/null +++ b/unittests/conftest.py @@ -0,0 +1,324 @@ +# 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 . + +""" +Shared pytest fixtures for DB-backed companion tests. + +Uses a DEDICATED scratch Postgres database (``_companion_test``) so tests +never touch the dev database. The scratch DB is created on first use, the pgvector +extension is enabled, and the full schema is built with ``create_all``. Each test +runs against it and all rows are deleted afterwards, so tests are isolated without +paying to rebuild the schema every time. + +Embeddings are stubbed (``stub_embedding``) to a fixed vector — indexing/retrieval +logic is what we test here, not the embedding model. +""" + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +import numpy as np +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.engine.url import make_url + +from config import Config +from app import create_app, db +from app.models import User, Company, ResearchProject +from app.models.research import ResearchTemplate +from app.models.journal import JournalEntry +from app.models.idea_pipeline import MistakeLog +from app.models.knowledge_chunk import KnowledgeChunk +from app.models.portfolio import PortfolioPosition + +_BASE_URL = make_url(Config.SQLALCHEMY_DATABASE_URI) +_SCRATCH_URL = _BASE_URL.set(database=_BASE_URL.database + '_companion_test') + + +class TestConfig(Config): + """Config pointed at the scratch DB; CSRF off for the test client.""" + # render_as_string(hide_password=False) — plain str(url) masks the password as "***". + SQLALCHEMY_DATABASE_URI = _SCRATCH_URL.render_as_string(hide_password=False) + TESTING = True + WTF_CSRF_ENABLED = False + + +def _ensure_scratch_db(): + """Create the scratch DB (if absent) and enable pgvector. + + Bootstraps from the dev DB connection (known-good credentials) rather than the + `postgres` maintenance DB, whose auth rules may differ. CREATE DATABASE works + from any autocommit connection. + """ + admin = create_engine(_BASE_URL, isolation_level='AUTOCOMMIT') + try: + with admin.connect() as conn: + exists = conn.execute( + text("SELECT 1 FROM pg_database WHERE datname = :n"), + {"n": _SCRATCH_URL.database}, + ).scalar() + if not exists: + conn.execute(text(f'CREATE DATABASE "{_SCRATCH_URL.database}"')) + finally: + admin.dispose() + + scratch = create_engine(_SCRATCH_URL, isolation_level='AUTOCOMMIT') + try: + with scratch.connect() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + finally: + scratch.dispose() + + +@pytest.fixture(scope='session') +def _app(): + """Session-scoped Flask app bound to the scratch DB with the schema built.""" + _ensure_scratch_db() + app = create_app(TestConfig) + with app.app_context(): + db.create_all() + yield app + + +@pytest.fixture +def app_context(_app): + """Push an app context; delete all rows afterwards to isolate tests.""" + with _app.app_context(): + yield + db.session.rollback() + # Delete children before parents to respect FK constraints. + for table in reversed(db.metadata.sorted_tables): + db.session.execute(table.delete()) + db.session.commit() + + +@pytest.fixture +def stub_embedding(monkeypatch): + """Replace the embedding service with a deterministic 768-dim vector.""" + class _FakeEmbeddingService: + def embed(self, text, use_cache=True): + # Deterministic pseudo-vector seeded by the text, so different texts + # get different vectors (needed by retrieval-ranking tests). + rng = np.random.default_rng(abs(hash(text)) % (2 ** 32)) + return rng.random(768, dtype=np.float64) + + fake = _FakeEmbeddingService() + # Patch every module that resolves the embedding service. + for modpath in ('app.services.argos.knowledge_index', + 'app.services.argos.knowledge_search'): + try: + monkeypatch.setattr(f'{modpath}.get_embedding_service', lambda: fake) + except (AttributeError, ImportError): + pass + return fake + + +# ========================================================================= +# Seed helpers +# ========================================================================= + +def _make_user(email='tester@example.com'): + user = User(email=email) + db.session.add(user) + db.session.flush() + return user + + +def _make_company(user_id, name='ASML Holding', ticker='ASML'): + company = Company(name=name, ticker_symbol=ticker, user_id=user_id) + db.session.add(company) + db.session.flush() + return company + + +def _make_project(user_id, company_id, findings=None): + template = ResearchTemplate(user_id=user_id, name='T', workflow_steps=[]) + db.session.add(template) + db.session.flush() + project = ResearchProject( + user_id=user_id, company_id=company_id, template_id=template.id, + key_findings=findings or [], + ) + db.session.add(project) + db.session.flush() + return project + + +def _make_chunk(user_id, company_id, source_type, source_id, summary): + chunk = KnowledgeChunk( + user_id=user_id, company_id=company_id, + source_type=source_type, source_id=source_id, + title=source_type, summary=summary, + embedding=np.random.default_rng(source_id + abs(hash(source_type)) % 1000) + .random(768).tolist(), + token_estimate=max(1, len(summary) // 4), + ) + db.session.add(chunk) + return chunk + + +@pytest.fixture +def seed_company_with_findings(app_context): + """A user + company + a research project carrying two key findings.""" + user = _make_user() + company = _make_company(user.id) + _make_project(user.id, company.id, findings=[ + 'The company has a durable moat from its distribution network and switching costs.', + 'Management has a consistent capital allocation record over the last decade.', + ]) + db.session.commit() + return user.id, company.id + + +@pytest.fixture +def seed_many_chunks(app_context): + """One user with 10 chunks in each of finding/journal/resource (30 total).""" + user = _make_user() + company = _make_company(user.id) + for source_type in ('finding', 'journal', 'resource'): + for i in range(10): + _make_chunk(user.id, company.id, source_type, i, + f'{source_type} {i}: notes on the competitive moat and advantage') + db.session.commit() + return user.id + + +@pytest.fixture +def other_user(app_context): + """A second user who owns no knowledge.""" + user = _make_user(email='other@example.com') + db.session.commit() + return user.id + + +@pytest.fixture +def client_logged_in(app_context, _app): + """A Flask test client with a logged-in user. Returns (client, user_id).""" + user = _make_user() + db.session.commit() + client = _app.test_client() + with client.session_transaction() as session: + session['_user_id'] = str(user.id) + session['_fresh'] = True + return client, user.id + + +@pytest.fixture +def seed_two_users(app_context): + """Two users; the second owns a company. Returns (u1_id, u2_id, u2_company_id).""" + u1 = _make_user(email='u1@example.com') + u2 = _make_user(email='u2@example.com') + db.session.flush() + u2_company = _make_company(u2.id) + db.session.commit() + return u1.id, u2.id, u2_company.id + + +@pytest.fixture +def seed_portfolio(app_context): + """A user with two active positions in European companies. Returns user_id.""" + user = _make_user() + c1 = _make_company(user.id, name='ASML Holding', ticker='ASML') + c2 = _make_company(user.id, name='SAP SE', ticker='SAP') + db.session.add(PortfolioPosition( + user_id=user.id, company_id=c1.id, is_active=True, + total_shares=100, current_value=140000)) + db.session.add(PortfolioPosition( + user_id=user.id, company_id=c2.id, is_active=True, + total_shares=50, current_value=60000)) + db.session.commit() + return user.id + + +@pytest.fixture +def seed_portfolio_with_history(app_context): + """A held company that also has one journal note and one logged mistake. + + Returns (user_id, company_id). + """ + user = _make_user() + company = _make_company(user.id) + db.session.add(PortfolioPosition( + user_id=user.id, company_id=company.id, is_active=True, + total_shares=10, current_value=50000)) + db.session.add(JournalEntry( + user_id=user.id, company_id=company.id, entry_type='observation', + title='Note', content='An observation about the company.')) + db.session.add(MistakeLog( + user_id=user.id, company_id=company.id, title='Overpaid', + description='Bought above intrinsic value.', mistake_type='valuation', + lesson_learned='Anchor to a valuation range before buying.')) + db.session.commit() + return user.id, company.id + + +@pytest.fixture +def seed_company_no_project(app_context): + """A user + company with NO research project and NO position. Returns (uid, cid).""" + user = _make_user() + company = _make_company(user.id) + db.session.commit() + return user.id, company.id + + +@pytest.fixture +def seed_company_completed_project(app_context): + """A user + company with a COMPLETED research project (decision + flags). Returns (uid, cid).""" + user = _make_user() + company = _make_company(user.id) + template = ResearchTemplate(user_id=user.id, name='T', workflow_steps=[]) + db.session.add(template) + db.session.flush() + db.session.add(ResearchProject( + user_id=user.id, company_id=company.id, template_id=template.id, + status='completed', decision='invest', + red_flags=['high debt load'], green_flags=['strong cash flow'], + investment_thesis='Durable moat with pricing power.')) + db.session.commit() + return user.id, company.id + + +@pytest.fixture +def seed_general_knowledge(app_context): + """A user with a company-less note and a company-less mistake. Returns user_id.""" + user = _make_user() + db.session.add(JournalEntry( + user_id=user.id, company_id=None, entry_type='observation', + title='General note', content='A general reflection not tied to a company.')) + db.session.add(MistakeLog( + user_id=user.id, company_id=None, title='Chased momentum', + description='Bought after a fast run-up without a thesis.', + mistake_type='behavioural', + lesson_learned='Require a written thesis before buying.')) + db.session.commit() + return user.id + + +@pytest.fixture +def seed_journal_resource(app_context): + """A user + company + one real JournalEntry. Returns (user_id, entry_id).""" + user = _make_user() + company = _make_company(user.id) + entry = JournalEntry( + user_id=user.id, company_id=company.id, + entry_type='observation', title='Moat note', + content='A note about the company moat and switching costs.', + ) + db.session.add(entry) + db.session.commit() + return user.id, entry.id diff --git a/unittests/test_account_map.py b/unittests/test_account_map.py new file mode 100644 index 00000000..8adb297b --- /dev/null +++ b/unittests/test_account_map.py @@ -0,0 +1,61 @@ +# 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 . + +"""Account map builder — cheap DB skeleton for the agent (Task 9). DB-backed.""" + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.services.argos.account_map import build_account_map, render_account_map + + +def test_account_map_shape_and_holdings(app_context, seed_portfolio): + uid = seed_portfolio + amap = build_account_map(uid, focus={'type': 'portfolio'}) + + assert amap['focus']['type'] == 'portfolio' + assert 'holdings' in amap and 'counts' in amap + assert amap['counts']['holdings'] == len(amap['holdings']) >= 1 + + holding = amap['holdings'][0] + assert {'company_id', 'name', 'ticker', 'weight_pct'} <= set(holding) + + +def test_render_account_map_is_nonempty_text(app_context, seed_portfolio): + text = render_account_map(build_account_map(seed_portfolio)) + assert isinstance(text, str) and text.strip() + + +def test_account_map_scoped_to_user(app_context, seed_portfolio, other_user): + assert build_account_map(other_user)['holdings'] == [] + + +def test_account_map_surfaces_journal_and_mistakes(app_context, seed_portfolio_with_history): + uid, cid = seed_portfolio_with_history + amap = build_account_map(uid) + + assert amap['counts']['journal_entries'] >= 1 + assert amap['counts']['mistakes'] >= 1 + + holding = next(h for h in amap['holdings'] if h['company_id'] == cid) + assert holding['notes'] >= 1 + assert holding['mistakes'] >= 1 + + # The rendered skeleton must advertise the mistake history to the agent. + text = render_account_map(amap) + assert 'mistake' in text.lower() diff --git a/unittests/test_base_mounts_companion.py b/unittests/test_base_mounts_companion.py new file mode 100644 index 00000000..34bc5e16 --- /dev/null +++ b/unittests/test_base_mounts_companion.py @@ -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 . + +"""Companion widget is global: auth-gated, opt-out (seamless chat, #300).""" + +import os + +from flask import render_template +from flask_login import login_user + +from app import db +from app.models.user import User + +BASE = os.path.join(os.path.dirname(__file__), '..', 'app/templates/main/_base.html') +WIDGET = os.path.join( + os.path.dirname(__file__), '..', 'app/templates/main/_companion_widget.html') + + +def test_base_includes_companion_widget(): + html = open(BASE, encoding='utf-8').read() + assert '_companion_widget.html' in html + + +def test_base_loads_markdown_libs_before_companion(): + """marked + DOMPurify are self-hosted and load before the companion widget.""" + html = open(BASE, encoding='utf-8').read() + marked_at = html.find('vendor/marked.min.js') + purify_at = html.find('vendor/purify.min.js') + widget_at = html.find('_companion_widget.html') + assert marked_at != -1 and purify_at != -1, 'markdown libs not loaded' + assert marked_at < widget_at and purify_at < widget_at, 'libs must precede companion.js' + + +def test_widget_guard_is_auth_gated_opt_out(): + """Renders for authenticated users unless a page explicitly opts out.""" + html = open(WIDGET, encoding='utf-8').read() + assert 'current_user.is_authenticated' in html + assert 'companion_enabled | default(true)' in html + assert 'data-focus-type' in html + + +def test_widget_renders_for_authenticated_user(app_context, _app): + user = User(email='mount@example.com') + db.session.add(user) + db.session.commit() + with _app.test_request_context('/'): + login_user(user) + html = render_template('main/_companion_widget.html') + assert 'companion-root' in html + + +def test_widget_hidden_for_anonymous(app_context, _app): + with _app.test_request_context('/'): + html = render_template('main/_companion_widget.html') # no login_user + assert 'companion-root' not in html + + +def test_companion_css_is_in_global_core_bundle(): + """The widget is global, so its CSS must live in css_core (not css_companies).""" + src = open(os.path.join(os.path.dirname(__file__), '..', 'app/assets.py'), + encoding='utf-8').read() + core_section = src[src.index('css_core = Bundle('):src.index('css_companies = Bundle(')] + assert "'css/modules/_companion.css'" in core_section diff --git a/unittests/test_companion_agent.py b/unittests/test_companion_agent.py new file mode 100644 index 00000000..0ce0b758 --- /dev/null +++ b/unittests/test_companion_agent.py @@ -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 . + +"""CompanionAgent wiring account map + tools + loop (Task 12). DB-backed.""" + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.services.argos import agent as agent_mod +from app.services.argos.agent import CompanionAgent +from app.services.ai.tool_calling import ToolLoopResult + + +def test_agent_ask_assembles_system_tools_and_messages(monkeypatch, app_context, seed_portfolio): + captured = {} + + def fake_generate_with_tools(messages, tools, executor, system=None, **kwargs): + captured['system'] = system + captured['tools'] = tools + captured['messages'] = messages + return ToolLoopResult(text='the answer', hops=1, calls=[]) + + monkeypatch.setattr(agent_mod.ai_service, 'generate_with_tools', fake_generate_with_tools) + + out = CompanionAgent(seed_portfolio).ask('what did I miss?', [], {'type': 'portfolio'}) + + assert out['answer'] == 'the answer' + assert out['hops'] == 1 + # Facts-only compliance rules must be in the system prompt. + assert 'opinions are yours' in captured['system'].lower() + # The account map (holdings) must be embedded in the system prompt. + assert 'portfolio' in captured['system'].lower() + # All six tools offered. + assert len(captured['tools']) == 6 + # The user's question is the final message. + assert captured['messages'][-1] == {'role': 'user', 'content': 'what did I miss?'} + + +def test_agent_prompt_gives_page_context_and_separates_it_from_account_map( + monkeypatch, app_context, seed_portfolio): + """Regression: on an unfocused page the agent must know the real page and not + describe the whole-account map as 'this page'.""" + captured = {} + + def fake_generate_with_tools(messages, tools, executor, system=None, **kwargs): + captured['system'] = system + return ToolLoopResult(text='x', hops=1, calls=[]) + + monkeypatch.setattr(agent_mod.ai_service, 'generate_with_tools', fake_generate_with_tools) + + CompanionAgent(seed_portfolio).ask( + 'what is this page?', [], + {'type': '', 'path': '/ideas/inbox', 'title': 'Idea Inbox'}) + + system = captured['system'] + # The real page reaches the prompt… + assert '/ideas/inbox' in system + assert 'Idea Inbox' in system + # …and the model is told the account map is the entire account, not the page. + assert 'entire account' in system.lower() + assert 'not the page' in system.lower() diff --git a/unittests/test_companion_js_contract.py b/unittests/test_companion_js_contract.py new file mode 100644 index 00000000..c10af03e --- /dev/null +++ b/unittests/test_companion_js_contract.py @@ -0,0 +1,116 @@ +# 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 . + +"""Static contract checks on companion.js (Task 14). No app/DB needed.""" + +import os + +JS = os.path.join(os.path.dirname(__file__), '..', 'app/static/js/companion.js') +WIDGET = os.path.join( + os.path.dirname(__file__), '..', 'app/templates/main/_companion_widget.html') + + +def _js(): + return open(JS, encoding='utf-8').read() + + +def test_js_posts_to_companion_ask_with_focus_and_history(): + s = _js() + assert '/ask' in s + assert 'focus' in s + assert 'history' in s + + +def test_js_persists_thread_in_sessionstorage(): + s = _js() + assert 'sessionStorage' in s + + +def test_js_has_focus_specific_quick_actions(): + s = _js() + assert 'What did I miss' in s # company focus + assert 'Where am I concentrated' in s # portfolio focus (factual, not a "risk") + assert 'Checkpoints due' in s # portfolio focus + assert 'Past mistakes' in s # company focus + assert 'renderQuickActions' in s + + +def test_js_threads_are_per_context(): + """Each focus context keeps its own thread (company/portfolio/project/general).""" + s = _js() + assert 'focusKey' in s + assert 'companion.thread:' in s + + +def test_js_persists_open_state(): + """Panel open/closed survives navigation (tab-global).""" + s = _js() + assert 'companion.open' in s + assert 'setOpen' in s + + +def test_js_resumes_pending_task_per_context(): + """A still-running answer is re-attached after navigation, per context.""" + s = _js() + assert 'companion.pending:' in s + assert 'resumePending' in s + assert 'startedAt' in s + + +def test_js_sends_current_page_context(): + """The companion tells the agent which page the user is on (URL + title).""" + s = _js() + assert 'window.location.pathname' in s + assert 'document.title' in s + + +def test_js_shows_scope_indicator(): + """Header names the current scope so a shared 'general' thread isn't confusing.""" + s = _js() + assert 'scopeLabel' in s + assert 'companionScope' in s + assert 'Across your whole account' in s # general + assert 'Focused on this company' in s # company + + +def test_js_renders_markdown_answers(): + """Answers are markdown from the agent; render via marked + DOMPurify (sanitized).""" + s = _js() + assert 'renderMarkdown' in s + assert 'marked.parse' in s # markdown -> HTML + assert 'DOMPurify.sanitize' in s # sanitize LLM output before innerHTML + # No longer rendered as plain escaped text: + assert 'this.escapeHtml(answer)' not in s + + +def test_widget_has_scope_element(): + html = open(WIDGET, encoding='utf-8').read() + assert 'companionScope' in html + + +def test_widget_exposes_focus_dataset(): + html = open(WIDGET, encoding='utf-8').read() + assert 'data-focus-type' in html + assert 'data-focus-company-id' in html + assert 'data-focus-project-id' in html + assert 'data-endpoint-base="/companion"' in html + + +if __name__ == '__main__': + test_js_posts_to_companion_ask_with_focus_and_history() + test_js_persists_thread_in_sessionstorage() + test_widget_exposes_focus_dataset() + print("PASS") diff --git a/unittests/test_companion_routes.py b/unittests/test_companion_routes.py new file mode 100644 index 00000000..2987a93f --- /dev/null +++ b/unittests/test_companion_routes.py @@ -0,0 +1,127 @@ +# 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 . + +"""The /companion blueprint: ask, capture, warnings (Task 13). DB-backed.""" + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +import json + +import app.companion.routes as companion_routes +from app import db +from app.models.journal import JournalEntry +from app.models.user import User +from app.models.company import Company +from app.models.background_task import BackgroundTask + + +def test_ask_route_returns_task_id(monkeypatch, client_logged_in): + client, _uid = client_logged_in + monkeypatch.setattr( + companion_routes.BackgroundTaskService, 'start_companion_ask', + staticmethod(lambda user_id, question, history, focus: 'task-123')) + + resp = client.post('/companion/ask', + json={'question': 'hi', 'history': [], 'focus': {}}) + + assert resp.status_code == 200 + assert resp.get_json()['data']['task_id'] == 'task-123' + + +def test_ask_status_returns_completed_result(client_logged_in): + client, uid = client_logged_in + db.session.add(BackgroundTask( + id='t-done', user_id=uid, task_type='companion_ask', status='completed', + result=json.dumps({'answer': 'the answer', 'hops': 1, 'tool_calls': []}))) + db.session.commit() + + resp = client.get('/companion/ask/status/t-done') + assert resp.status_code == 200 + assert resp.get_json()['data']['result']['answer'] == 'the answer' + + +def test_ask_status_denies_foreign_task(client_logged_in): + client, _uid = client_logged_in + other = User(email='foreign@example.com') + db.session.add(other) + db.session.flush() + db.session.add(BackgroundTask( + id='t-foreign', user_id=other.id, task_type='companion_ask', status='completed')) + db.session.commit() + + resp = client.get('/companion/ask/status/t-foreign') + assert resp.status_code == 404 + + +def test_ask_route_rejects_empty_question(client_logged_in): + client, _uid = client_logged_in + resp = client.post('/companion/ask', json={'question': ' ', 'focus': {}}) + assert resp.status_code == 400 + + +def test_capture_route_creates_journal_entry(client_logged_in): + client, uid = client_logged_in + resp = client.post('/companion/capture', + json={'text': 'A captured insight.', 'source_title': 'Blog', + 'url': 'https://example.com', 'focus': {}}) + + assert resp.status_code == 200 + entry_id = resp.get_json()['data']['entry_id'] + entry = JournalEntry.query.get(entry_id) + assert entry is not None and entry.user_id == uid + + +def test_capture_links_owned_company(client_logged_in): + client, uid = client_logged_in + company = Company(name='ASML Holding', ticker_symbol='ASML', user_id=uid) + db.session.add(company) + db.session.commit() + + resp = client.post('/companion/capture', json={ + 'text': 'A note about ASML.', + 'focus': {'type': 'company', 'company_id': company.id}}) + + assert resp.status_code == 200 + entry = JournalEntry.query.get(resp.get_json()['data']['entry_id']) + assert entry.company_id == company.id + + +def test_capture_ignores_unowned_company(client_logged_in): + client, uid = client_logged_in + other = User(email='foreign@example.com') + db.session.add(other) + db.session.flush() + foreign_company = Company(name='SAP SE', ticker_symbol='SAP', user_id=other.id) + db.session.add(foreign_company) + db.session.commit() + + resp = client.post('/companion/capture', json={ + 'text': 'Trying to attach to a foreign company.', + 'focus': {'type': 'company', 'company_id': foreign_company.id}}) + + # Capture still succeeds, but the unowned company id is rejected → linked to nothing. + assert resp.status_code == 200 + entry = JournalEntry.query.get(resp.get_json()['data']['entry_id']) + assert entry.company_id is None + + +def test_ask_requires_login(_app): + # No session → login required → not a 200. + resp = _app.test_client().post('/companion/ask', json={'question': 'hi'}) + assert resp.status_code != 200 diff --git a/unittests/test_companion_widget_markup.py b/unittests/test_companion_widget_markup.py new file mode 100644 index 00000000..939ff957 --- /dev/null +++ b/unittests/test_companion_widget_markup.py @@ -0,0 +1,42 @@ +# 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 . + +"""Widget partial has no inline JS and exposes a config root (Task 1).""" + +import os +import sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +WIDGET = os.path.join( + os.path.dirname(__file__), '..', + 'app/templates/main/_companion_widget.html') + + +def test_widget_has_no_inline_script(): + html = open(WIDGET, encoding='utf-8').read() + assert 'const CompanionChat' not in html, "inline JS must move to companion.js" + + +def test_widget_exposes_config_root(): + html = open(WIDGET, encoding='utf-8').read() + assert 'id="companion-root"' in html + assert 'data-endpoint-base' in html + + +if __name__ == '__main__': + test_widget_has_no_inline_script() + test_widget_exposes_config_root() + print("PASS") diff --git a/unittests/test_company_context.py b/unittests/test_company_context.py new file mode 100644 index 00000000..96aa20b7 --- /dev/null +++ b/unittests/test_company_context.py @@ -0,0 +1,52 @@ +# 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 . + +"""Project-free company context for the companion (Task 11). DB-backed.""" + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.services.argos.core import ArgosService + + +def test_build_company_context_without_project(app_context, seed_company_no_project): + uid, cid = seed_company_no_project + summary = ArgosService(uid).build_company_context(cid).to_summary() + + assert summary['company_id'] == cid + assert 'position' in summary + assert summary['position'] is None # not held + + +def test_build_company_context_with_held_position(app_context, seed_portfolio_with_history): + uid, cid = seed_portfolio_with_history + summary = ArgosService(uid).build_company_context(cid).to_summary() + + assert summary['company_id'] == cid + assert isinstance(summary['position'], dict) + assert 'days_held' in summary['position'] + + +def test_build_company_context_uses_completed_project(app_context, seed_company_completed_project): + uid, cid = seed_company_completed_project + summary = ArgosService(uid).build_company_context(cid).to_summary() + + assert summary['latest_decision'] == 'invest' + assert 'completed' in summary['project_state'] + assert 'high debt load' in summary['red_flags'] + assert summary['investment_thesis'] == 'Durable moat with pricing power.' diff --git a/unittests/test_knowledge_chunk_model.py b/unittests/test_knowledge_chunk_model.py new file mode 100644 index 00000000..44462824 --- /dev/null +++ b/unittests/test_knowledge_chunk_model.py @@ -0,0 +1,41 @@ +# 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 . + +"""KnowledgeChunk model shape (Task 6). Import-only, no DB required.""" + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +import app.models as models +from app.models.knowledge_chunk import KnowledgeChunk + + +def test_knowledge_chunk_fields(): + for f in ['user_id', 'company_id', 'source_type', 'source_id', + 'title', 'summary', 'embedding', 'token_estimate', 'updated_at']: + assert hasattr(KnowledgeChunk, f), f"missing {f}" + + +def test_knowledge_chunk_exported_from_models_package(): + assert hasattr(models, 'KnowledgeChunk'), "KnowledgeChunk not registered in app.models" + + +if __name__ == '__main__': + test_knowledge_chunk_fields() + test_knowledge_chunk_exported_from_models_package() + print("PASS") diff --git a/unittests/test_knowledge_index.py b/unittests/test_knowledge_index.py new file mode 100644 index 00000000..48471062 --- /dev/null +++ b/unittests/test_knowledge_index.py @@ -0,0 +1,56 @@ +# 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 . + +"""Knowledge index build + idempotency (Task 7). DB-backed (scratch Postgres).""" + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.models.knowledge_chunk import KnowledgeChunk +from app.services.argos.knowledge_index import ( + index_company_knowledge, index_general_knowledge) + + +def test_index_creates_chunks_and_is_idempotent(app_context, seed_company_with_findings, stub_embedding): + uid, cid = seed_company_with_findings + + n1 = index_company_knowledge(uid, cid) + n2 = index_company_knowledge(uid, cid) + + assert n1 > 0, "expected at least one chunk from the seeded findings" + assert n2 == n1, "re-indexing must upsert, not duplicate" + assert KnowledgeChunk.query.filter_by(company_id=cid).count() == n1 + + +def test_index_includes_company_mistakes(app_context, seed_portfolio_with_history, stub_embedding): + uid, cid = seed_portfolio_with_history + index_company_knowledge(uid, cid) + + assert KnowledgeChunk.query.filter_by(company_id=cid, source_type='mistake').count() == 1 + assert KnowledgeChunk.query.filter_by(company_id=cid, source_type='journal').count() == 1 + + +def test_index_general_knowledge_covers_companyless_items(app_context, seed_general_knowledge, stub_embedding): + uid = seed_general_knowledge + n = index_general_knowledge(uid) + + assert n == 2 # one general note + one general mistake + assert KnowledgeChunk.query.filter_by( + user_id=uid, company_id=None, source_type='mistake').count() == 1 + assert KnowledgeChunk.query.filter_by( + user_id=uid, company_id=None, source_type='journal').count() == 1 diff --git a/unittests/test_knowledge_search.py b/unittests/test_knowledge_search.py new file mode 100644 index 00000000..91dc0448 --- /dev/null +++ b/unittests/test_knowledge_search.py @@ -0,0 +1,50 @@ +# 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 . + +"""Budgeted knowledge retrieval: caps, ceiling, ownership (Task 8). DB-backed.""" + +import os +import sys +from collections import Counter + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.services.argos.knowledge_search import search_my_knowledge, get_resource + + +def test_respects_total_and_per_source_caps(app_context, seed_many_chunks, stub_embedding): + uid = seed_many_chunks # many findings + journal + resources for one user + out = search_my_knowledge( + uid, "moat competitive advantage", total_cap=8, + per_source_caps={'finding': 3, 'journal': 3, 'resource': 2, 'decision': 3}) + + assert len(out) <= 8, "total ceiling must hold" + counts = Counter(o['source_type'] for o in out) + assert counts['finding'] <= 3 + assert counts['journal'] <= 3 + assert counts['resource'] <= 2 + + +def test_search_only_returns_own_chunks(app_context, seed_many_chunks, other_user, stub_embedding): + # other_user has no chunks of their own → empty result, never the seeded user's. + out = search_my_knowledge(other_user, "moat", total_cap=8) + assert out == [] + + +def test_get_resource_denies_other_user(app_context, seed_journal_resource, other_user): + owner_id, journal_id = seed_journal_resource + assert get_resource(owner_id, 'journal', journal_id) is not None + assert get_resource(other_user, 'journal', journal_id) is None diff --git a/unittests/test_target_pages_enable_companion.py b/unittests/test_target_pages_enable_companion.py new file mode 100644 index 00000000..d61a2091 --- /dev/null +++ b/unittests/test_target_pages_enable_companion.py @@ -0,0 +1,41 @@ +# 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 . + +"""Target pages set companion_focus; the widget is now global (no per-page flag).""" + +import os + +ROOT = os.path.join(os.path.dirname(__file__), '..') + +CASES = [ + ('app/companies/templates/company_detail.html', "'company'"), + ('app/portfolio/templates/portfolio_dashboard.html', "'portfolio'"), + ('app/research_workflow/templates/execute_step.html', "'research'"), +] + + +def test_target_pages_set_focus_without_flag(): + for path, focus_type in CASES: + html = open(os.path.join(ROOT, path), encoding='utf-8').read() + normalised = html.replace(' ', '') + assert 'companion_enabled=true' not in normalised, f"{path}: flag should be gone (widget is global)" + assert 'companion_focus' in html, f"{path}: missing companion_focus" + assert focus_type in html, f"{path}: missing focus type {focus_type}" + + +if __name__ == '__main__': + test_target_pages_set_focus_without_flag() + print("PASS") diff --git a/unittests/test_tool_calling.py b/unittests/test_tool_calling.py new file mode 100644 index 00000000..9e8bce91 --- /dev/null +++ b/unittests/test_tool_calling.py @@ -0,0 +1,129 @@ +# 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 . + +"""Provider-agnostic tool-calling types + agentic loop (Tasks 3-5).""" + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.services.ai import ai_service +from app.services.ai.tool_calling import ( + ToolSpec, ToolCall, ToolResult, TurnResult, ToolLoopResult, run_tool_loop, +) + + +# -------------------------------------------------------------------------- +# Task 3 — types +# -------------------------------------------------------------------------- + +def test_toolspec_roundtrips(): + spec = ToolSpec(name="ping", description="d", parameters={"type": "object", "properties": {}}) + assert spec.name == "ping" + call = ToolCall(id="1", name="ping", arguments={}) + res = ToolResult(id="1", content="pong") + assert res.id == call.id + + +# -------------------------------------------------------------------------- +# Task 4 — the agentic loop (fake provider, no network) +# -------------------------------------------------------------------------- + +def _fake_provider(script): + """A provider whose generate_turn returns scripted TurnResults in order.""" + state = {'i': 0} + + class FakeProvider: + def supports_tools(self): + return True + + def generate_turn(self, messages, tools, system=None, max_tokens=1024, temperature=0.3): + step = script[state['i']] + state['i'] += 1 + return TurnResult(text=step.get('text'), tool_calls=step.get('tool_calls', [])) + + return FakeProvider() + + +def test_loop_executes_tool_then_answers(): + provider = _fake_provider([ + {'tool_calls': [ToolCall(id='a', name='get', arguments={'x': 1})]}, + {'text': 'final answer'}, + ]) + seen = [] + + def executor(call): + seen.append(call.name) + return ToolResult(id=call.id, content='DATA') + + result = run_tool_loop(provider, [{'role': 'user', 'content': 'q'}], + [ToolSpec('get', 'd', {'type': 'object', 'properties': {}})], + executor, max_hops=5) + assert result.text == 'final answer' + assert result.hops == 1 + assert seen == ['get'] + + +def test_loop_stops_at_hop_cap(): + script = [{'tool_calls': [ToolCall(id=str(i), name='get', arguments={})]} for i in range(10)] + script.append({'text': 'forced'}) + provider = _fake_provider(script) + + def executor(call): + return ToolResult(id=call.id, content='D') + + result = run_tool_loop(provider, [{'role': 'user', 'content': 'q'}], + [ToolSpec('get', 'd', {'type': 'object', 'properties': {}})], + executor, max_hops=3) + assert result.hops == 3 # capped + + +# -------------------------------------------------------------------------- +# Task 5 — ai_service.generate_with_tools routing +# -------------------------------------------------------------------------- + +def test_ai_service_generate_with_tools_routes(monkeypatch): + fake = _fake_provider([{'text': 'ok'}]) + monkeypatch.setattr(ai_service, '_get_provider', lambda *a, **k: fake) + out = ai_service.generate_with_tools( + [{'role': 'user', 'content': 'hi'}], + [ToolSpec('t', 'd', {'type': 'object', 'properties': {}})], + lambda c: ToolResult(id=c.id, content='x')) + assert out.text == 'ok' + + +def test_ai_service_rejects_provider_without_tools(monkeypatch): + + class NoTools: + def supports_tools(self): + return False + model_name = 'no-tools' + + monkeypatch.setattr(ai_service, '_get_provider', lambda *a, **k: NoTools()) + try: + ai_service.generate_with_tools([{'role': 'user', 'content': 'hi'}], [], + lambda c: ToolResult(id=c.id, content='x')) + assert False, "expected RuntimeError" + except RuntimeError as e: + assert 'tool' in str(e).lower() + + +if __name__ == '__main__': + test_toolspec_roundtrips() + test_loop_executes_tool_then_answers() + test_loop_stops_at_hop_cap() + print("PASS (loop tests; pytest for monkeypatch tests)") diff --git a/unittests/test_tool_executor.py b/unittests/test_tool_executor.py new file mode 100644 index 00000000..69487108 --- /dev/null +++ b/unittests/test_tool_executor.py @@ -0,0 +1,64 @@ +# 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 . + +"""Tool schemas + ownership-bound ToolExecutor (Task 10). DB-backed.""" + +import os +import sys +import json + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.services.ai.tool_calling import ToolCall +from app.services.argos.tools import COMPANION_TOOLS, ToolExecutor + + +def test_tool_registry_has_six_tools(): + names = {t.name for t in COMPANION_TOOLS} + assert names == { + 'get_portfolio_overview', 'get_company_context', 'get_research_project', + 'search_my_knowledge', 'get_resource', 'get_mistakes_and_patterns', + } + + +def test_executor_denies_foreign_company(app_context, seed_two_users): + u1, u2, u2_company = seed_two_users + result = ToolExecutor(user_id=u1)( + ToolCall(id='1', name='get_company_context', arguments={'company_id': u2_company})) + payload = json.loads(result.content) + assert 'error' in payload # ownership denied, no data leaked + + +def test_executor_unknown_tool(app_context, seed_two_users): + u1, _, _ = seed_two_users + result = ToolExecutor(user_id=u1)(ToolCall(id='1', name='nope', arguments={})) + assert 'unknown tool' in result.content.lower() + + +def test_executor_runs_owned_company(app_context, seed_company_no_project): + uid, cid = seed_company_no_project + result = ToolExecutor(user_id=uid)( + ToolCall(id='1', name='get_company_context', arguments={'company_id': cid})) + payload = json.loads(result.content) + assert payload['company_id'] == cid + + +def test_mistakes_tool_returns_user_mistakes(app_context, seed_portfolio_with_history): + uid, _cid = seed_portfolio_with_history + result = ToolExecutor(user_id=uid)( + ToolCall(id='1', name='get_mistakes_and_patterns', arguments={})) + payload = json.loads(result.content) + assert len(payload['mistakes']) >= 1 # previously always empty (company_id=0 bug)