Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ Built on Elixir + Phoenix LiveView. Consolidates a previous multi-stack ecosyste
- **Dividends** — a cash ledger with IBKR / MyInvestor statement import, per-currency charts (12-month + full-history), a payment calendar, and upcoming ex-dividend tracking.
- **Path to Freedom** — a FIRE projection (three capital pools, inflation, adaptive drawdown) with live sensitivity levers.
- **Community** — opt-in public portfolio (`/p/:slug`) and radar (`/r/:slug`) pages with share images, plus a `/community` dashboard and trending.
- **AI** — portfolio/radar insights and per-stock summaries (Gemini, provider-agnostic), with a per-user daily quota.
- **Ask Quantic (AI chat)** — an always-present assistant on every page. Ask in plain language about your portfolio, radar, dividends, or any stock; answers show the referenced stocks' logos, and the conversation follows you as you navigate. Informational only — not financial advice.
- **AI insights** — portfolio/radar insights and per-stock dividend summaries (yield, payout sustainability, valuation, 52-week position) — observations, strengths, and risks, never buy/sell calls. Gemini, provider-agnostic, with a per-user daily quota.
- **Telegram** — an account-linked bot (queries + a daily digest of ex-divs, dividends, and target hits).
- **Seven languages** — en, es, ca, pt, de, fr, it.
- **Demo mode** — "Try a sample portfolio" seeds a real, ephemeral account so every feature is visible without signing up.
Expand Down
4 changes: 3 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ config :quantic, Quantic.Mailer, adapter: Swoosh.Adapters.Test
# Swap the live market-data provider for a Mox-generated mock; tests set
# expectations per-test via Mox.expect/3.
config :quantic, Quantic.MarketData, provider: Quantic.MarketData.ProviderMock
config :quantic, Quantic.AI, provider: Quantic.AI.ProviderMock
# Pin the daily AI quota for deterministic rate-limit tests, independent
# of the production default.
config :quantic, Quantic.AI, provider: Quantic.AI.ProviderMock, daily_limit: 3

# Oban in manual testing mode: no queues poll, no plugins run; jobs are
# executed explicitly via Oban.Testing helpers (perform_job/3).
Expand Down
12 changes: 8 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ lib/
community/ # public surface (← pulse): /p/:slug, /r/:slug, /community + visit analytics
events/ # time-series ingest + read API (← trends)
logos/ # logo cache + serving + acquisition pipeline (GitHub → Wikidata → LLM)
ai/ # provider-agnostic AI (insights, summaries) + per-user quota + Telegram bot + daily digest
ai/ # provider-agnostic AI (insights, summaries) + per-user quota + scoped chat tools (Telegram bot + Ask widget) + daily digest
content/ # founder social-post drafts (← legacy content_drafts): topic select → build → generate
demo/ # ephemeral seeded demo users + nightly sweep
quantic_web/ # web layer: LiveViews, controllers, layouts
Expand Down Expand Up @@ -125,13 +125,17 @@ Stock **enrichment** (sector/industry + inferred dividend payment schedule) runs

`Quantic.AI` is a provider-agnostic seam over LLM calls. The `Quantic.AI.Provider` behaviour has just two generic callbacks — `chat/2` (with an internally-walked tool loop) and `generate_json/3` — so adding a provider is two functions, not a per-feature re-port. The Gemini adapter (Req, `Req.Test`-stubbed) is the live implementation.

Every scoped call (`chat_as/3`, `generate_json_as/4`) enforces a **per-user daily quota** (`ai_requests` table, 3/day UTC, config-overridable) recorded *before* the call (a failed LLM call still costs money), with one ordering invariant pinned by test: the **cache probe runs before the quota check**, so a rate-limited user still sees cached results. **Admins bypass the quota** (legacy `AiRateLimiter` parity). System-initiated calls (`generate_json/3`, logo LLM resolver, content drafts) bypass the quota entirely.
Every scoped call (`chat_as/3`, `generate_json_as/4`) enforces a **per-user daily quota** (`ai_requests` table, 5/day UTC, config-overridable). A call is recorded only when it **actually reaches the provider** — a real provider failure still counts (it spent money), but a pre-flight failure that never called out (e.g. a missing API key) does not burn the quota. One ordering invariant is pinned by test: the **cache probe runs before the quota check**, so a rate-limited user still sees cached results. **Admins bypass the quota** (legacy `AiRateLimiter` parity); **demo accounts never reach a provider at all** (the chat returns a canned reply). System-initiated calls (`generate_json/3`, logo LLM resolver, content drafts) bypass the quota entirely.

Features: portfolio/radar **insights** and per-stock **summaries** (`AI.Insights`, 6h input-digest cache), the **Telegram bot's brain**, and **content drafts** (below). All need `GEMINI_API_KEY` to light up; without it each AI feature degrades independently.
Features: portfolio/radar **insights** and per-stock **summaries** (`AI.Insights`, 6h input-digest cache), the **Ask Quantic chat widget** (`QuanticWeb.AskLive`), the **Telegram bot's brain**, and **content drafts** (below). All need `GEMINI_API_KEY` to light up; without it each AI feature degrades independently.

**Not financial advice — by construction.** The insights, summaries, and chat are *informational*: they describe a stock on dividend criteria (yield, payout ratio, P/E, MA200, 52-week position) and surface observations, strengths, and risks, but never emit a buy/sell/hold verdict or a "good/bad buy" rating. This is enforced in the prompts and schemas (the per-stock summary has no `verdict`; insights expose neutral `observations`, not `buyingOpportunities`) and matches the app's footer disclaimer ("not a recommendation to buy or sell any security"). The chat also carries an in-widget "not financial advice" note.

The chat tools live in `Quantic.AI.PortfolioTools` (`all_for/1`) — seven user-scoped tools over the contexts' public APIs, closing over the caller's `Scope` so the LLM can only ever read that user's own data. They have **two front doors**: the Telegram bot and `AskLive`. The web widget is a **sticky nested LiveView** mounted once in `Layouts.app`, so its conversation survives `live_navigate` across the authenticated pages; a `handle_params` `on_mount` hook (`QuanticWeb.AskContext`) broadcasts the current page over the internal `ask_context:<user_id>` PubSub topic the widget subscribes to, giving each answer page context. Open state + conversation are mirrored to `localStorage` (a colocated JS hook), so they survive a refresh or cross-`live_session` navigation. Each answer renders `<.stock_logo>` chips for the stocks it referenced, derived from the model's actual `tool_calls` (not a regex on prose). No new AI infrastructure — it reuses `chat_as/3` (quota, tool loop) wholesale.

## Telegram

`Quantic.Telegram` ports legacy's bot (`Handler`/`Nlu`/`Tools`/`Client`) + linking lifecycle (`user_telegram_links`, one-shot expiring codes → `/start <code>`). Seven user-scoped tools close over the linked user's data (the privacy property the system prompt promises). A **daily digest** (Oban cron, 06:30 UTC) sends upcoming ex-divs, dividends received, and throttled radar target-hit alerts, in the user's profile locale.
`Quantic.Telegram` ports legacy's bot (`Handler`/`Nlu`/`Client`) + linking lifecycle (`user_telegram_links`, one-shot expiring codes → `/start <code>`). Its NLU tools now live in `Quantic.AI.PortfolioTools` (shared with the web chat widget); the seven user-scoped tools close over the linked user's data (the privacy property the system prompt promises). A **daily digest** (Oban cron, 06:30 UTC) sends upcoming ex-divs, dividends received, and throttled radar target-hit alerts, in the user's profile locale.

**Ships dormant**: the monolith never registers the webhook on boot (doing so on the shared prod bot token would detach legacy's webhook), and the notifier is config-gated off. The one explicit cutover step — `Telegram.Client.register_webhook/1` + `TELEGRAM_NOTIFICATIONS=true` — attaches the bot to the monolith. Same bot token everywhere; verified by simulated-update tests since beta can't exercise it live.

Expand Down
23 changes: 15 additions & 8 deletions lib/quantic/ai.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defmodule Quantic.AI do
`chat_as/3` / `generate_json_as/4`, which enforce the daily limit and
record the request before delegating to the configured provider.
AI inference costs real money — the limit keeps the service free for
everyone (#{3}/day per user; config-overridable). **Admins bypass the
everyone (#{5}/day per user; config-overridable). **Admins bypass the
limit entirely** (legacy `AiRateLimiter` parity — the founder's own
tooling, e.g. content drafts, must never be throttled); their calls are
still recorded so usage/cost stats stay accurate.
Expand All @@ -23,7 +23,7 @@ defmodule Quantic.AI do
alias Quantic.AI.Request
alias Quantic.Repo

@default_daily_limit 3
@default_daily_limit 5

@typedoc "The rate-limit verdict, shaped for UI display."
@type quota :: %{
Expand Down Expand Up @@ -97,8 +97,7 @@ defmodule Quantic.AI do
defp with_quota(%Scope{user: %{admin: true} = user}, feature, fun) do
# Legacy parity: admins bypass the daily limit. Still recorded so
# usage/cost stats (and the admin dashboard) stay accurate.
record_request(user, feature)
fun.()
fun.() |> record_unless_free(user, feature)
end

defp with_quota(%Scope{user: user} = scope, feature, fun) do
Expand All @@ -107,13 +106,21 @@ defmodule Quantic.AI do
{:error, {:rate_limited, quota}}

_allowed ->
# Record BEFORE the call (legacy did too): a slow/failed LLM
# call still spent real money and counts against the quota.
record_request(user, feature)
fun.()
fun.() |> record_unless_free(user, feature)
end
end

# A real LLM call — success OR a provider-side error — spent money, so
# it counts against the quota (legacy parity). But a failure that never
# reached the provider (e.g. a missing API key) cost nothing and must
# not burn the user's daily attempts.
defp record_unless_free({:error, :missing_api_key} = result, _user, _feature), do: result

defp record_unless_free(result, user, feature) do
record_request(user, feature)
result
end

defp record_request(%{id: user_id}, feature) do
Repo.insert!(%Request{user_id: user_id, feature: feature, provider: provider().name()})
end
Expand Down
32 changes: 14 additions & 18 deletions lib/quantic/ai/insights.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ defmodule Quantic.AI.Insights do
alias Quantic.Radar

@analyst_system """
You are a dividend investment analyst assistant. Analyze the user's stocks and provide actionable insights.
Focus on dividend investing strategy: yield quality, payout sustainability, portfolio diversification by payment months, and value opportunities.
Be concise and specific. Reference stocks by their symbol.
You are a dividend-investing information assistant. Describe what is notable about the user's stocks as information, not advice.
Focus on dividend-investing criteria: yield quality, payout sustainability, portfolio diversification by payment months, and valuation.
Be concise and specific. Reference stocks by their symbol. Do NOT recommend buying, selling, or holding, and do NOT call anything a "good" or "bad" buy — present observations, strengths, and risks and let the user decide.
Prices are denominated in each stock's quoted currency, see the `currency` field on every row; do not assume a single currency across the portfolio.
IMPORTANT: The "targetPrice" field is NOT an analyst target — it is the price at which the user personally wants to act (buy or sell). Treat it as the user's desired action price.
"""
Expand All @@ -36,15 +36,15 @@ defmodule Quantic.AI.Insights do
"type" => "object",
"properties" => %{
"summary" => %{"type" => "string"},
"buyingOpportunities" => %{
"observations" => %{
"type" => "array",
"items" => %{
"type" => "object",
"properties" => %{
"symbol" => %{"type" => "string"},
"reason" => %{"type" => "string"}
"note" => %{"type" => "string"}
},
"required" => ["symbol", "reason"]
"required" => ["symbol", "note"]
}
},
"coverageGaps" => %{"type" => "string"},
Expand All @@ -61,20 +61,16 @@ defmodule Quantic.AI.Insights do
},
"strengths" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["summary", "buyingOpportunities", "coverageGaps", "riskFlags", "strengths"]
"required" => ["summary", "observations", "coverageGaps", "riskFlags", "strengths"]
}

@summary_schema %{
"type" => "object",
"properties" => %{
"summary" => %{"type" => "string"},
"verdict" => %{
"type" => "string",
"enum" => ["strong_buy", "buy", "hold", "caution", "avoid"]
},
"keyPoints" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["summary", "verdict", "keyPoints"]
"required" => ["summary", "keyPoints"]
}

@doc "AI insights over the scope's holdings. `{:ok, map} | {:error, _} | :empty`."
Expand All @@ -91,7 +87,7 @@ defmodule Quantic.AI.Insights do

Provide:
1. A brief portfolio summary (2-3 sentences)
2. Buying opportunities (stocks that could strengthen the portfolio, or existing positions worth adding to)
2. Notable observations (e.g. holdings trading below the user's target price, or near a 52-week low) — describe what's notable; do NOT recommend buying
3. Dividend coverage gaps (months with no dividend income)
4. Risk flags (high payout ratios, low scores, concentration risk, stocks trading well above MA200)
5. Portfolio strengths (good diversification, strong yields, consistent payers)
Expand All @@ -113,7 +109,7 @@ defmodule Quantic.AI.Insights do

Provide:
1. A brief portfolio summary (2-3 sentences)
2. Buying opportunities (stocks trading below target or near 52-week lows with good fundamentals)
2. Notable observations (stocks trading below the user's target, or near a 52-week low) — describe what's notable; do NOT recommend buying
3. Dividend coverage gaps (months with no dividend income)
4. Risk flags (high payout ratios, low scores, stocks trading well above MA200)
5. Portfolio strengths (good diversification, strong yields, consistent payers)
Expand All @@ -133,17 +129,17 @@ defmodule Quantic.AI.Insights do

data ->
prompt = """
Assess this stock for dividend investing:
Describe how this stock looks on dividend-investing criteria:

#{Jason.encode!(data)}

Provide a 2-3 sentence summary, a verdict (strong_buy, buy, hold, caution, or avoid), and 2-3 key bullet points.
Provide a 2-3 sentence factual summary and 2-3 key bullet points covering its strengths and risks. Describe the data; do NOT give a verdict or a buy/sell/hold recommendation.
"""

cached_generate(scope, "stock_summary", data, prompt, @summary_schema, """
You are a dividend investment analyst assistant. Provide a concise assessment of an individual stock for dividend investing.
You are a dividend-investing information assistant. Describe how an individual stock looks on dividend criteria — as information, never as advice.
Consider yield, payout ratio, PE ratio, price vs target, 52-week position, and MA200 trend.
Be specific and actionable. Prices are denominated in this stock's quoted currency, see the `currency` field.
Be specific and factual. Do NOT recommend buying, selling, or holding, and do NOT rate the stock — present the data, strengths, and risks and let the user decide. Prices are denominated in this stock's quoted currency, see the `currency` field.
IMPORTANT: The "targetPrice" field is NOT an analyst target — it is the price at which the user personally wants to act (buy or sell). Treat it as the user's desired action price.
""")
end
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
defmodule Quantic.Telegram.Tools do
defmodule Quantic.AI.PortfolioTools do
@moduledoc """
The bot's tool registry (← legacy `TelegramBot::Tools` + the seven
tool classes, collapsed onto the contexts' public APIs). Every tool
closes over the linked user's scope, so the LLM can only ever touch
that user's own data — the privacy property the system prompt
The shared tool registry for AI chat (← legacy `TelegramBot::Tools` +
the seven tool classes, collapsed onto the contexts' public APIs).
Every tool closes over the user's scope, so the LLM can only ever
touch that user's own data — the privacy property the system prompt
promises.

Pure context-API plumbing with two front doors: the Telegram bot
(`Quantic.Telegram.Handler`) and the web chat widget
(`QuanticWeb.AskLive`).
"""

alias Quantic.Accounts.Scope
Expand Down Expand Up @@ -286,6 +290,7 @@ defmodule Quantic.Telegram.Tools do
defp quote_context(quote) do
%{
dividend_yield: quote.dividend_yield,
payout_ratio: quote.payout_ratio,
pe_ratio: quote.pe_ratio,
ma_200: quote.ma200,
fifty_two_week_high: quote.fifty_two_week_high,
Expand Down
2 changes: 1 addition & 1 deletion lib/quantic/telegram/handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ defmodule Quantic.Telegram.Handler do

alias Quantic.Accounts.Scope
alias Quantic.AI
alias Quantic.AI.PortfolioTools, as: Tools
alias Quantic.Telegram
alias Quantic.Telegram.Client
alias Quantic.Telegram.Tools

@system_prompt """
You are Quantic's dividend-investing assistant, embedded in Telegram. The user has connected their Quantic account, so you can answer questions about their personal radar (watchlist), holdings (portfolio), and dividends.
Expand Down
44 changes: 44 additions & 0 deletions lib/quantic_web/ask_context.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
defmodule QuanticWeb.AskContext do
@moduledoc """
Tells the always-present AI chat widget (`QuanticWeb.AskLive`) which
page is on screen, so it can label the conversation context and bias
ambiguous questions toward it.

Attached as an `on_mount` hook in the authenticated `live_session`: it
installs a `handle_params` hook that, on every navigation, broadcasts
the current page to the user's `ask_context:<id>` PubSub topic — the
same internal-PubSub pattern the rest of the app uses for cross-process
coordination. The sticky widget subscribes to that topic.
"""

use Gettext, backend: QuanticWeb.Gettext

def on_mount(:default, _params, _session, socket) do
socket =
Phoenix.LiveView.attach_hook(socket, :ask_context, :handle_params, fn _params,
_uri,
socket ->
broadcast_page(socket)
{:cont, socket}
end)

{:cont, socket}
end

defp broadcast_page(socket) do
with %{user: %{id: user_id}} <- socket.assigns[:current_scope],
label when is_binary(label) <- page_label(socket.view) do
Phoenix.PubSub.broadcast(Quantic.PubSub, "ask_context:#{user_id}", {:current_page, label})
end

:ok
end

defp page_label(QuanticWeb.PortfolioLive), do: gettext("Portfolio")
defp page_label(QuanticWeb.RadarLive), do: gettext("Radar")
defp page_label(QuanticWeb.BuyPlanLive), do: gettext("Buy plan")
defp page_label(QuanticWeb.PathToFreedomLive), do: gettext("Path to Freedom")
defp page_label(QuanticWeb.DividendsLive), do: gettext("Dividends")
defp page_label(QuanticWeb.SettingsLive), do: gettext("Settings")
defp page_label(_other), do: nil
end
Loading
Loading