mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 14:48:48 +02:00
Compare commits
33 Commits
254b4c751e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d0547743f | ||
|
|
8ceed0ab2e | ||
|
|
ca3115e99c | ||
|
|
3518ff58c4 | ||
|
|
352b77ea07 | ||
|
|
fbc0816be6 | ||
|
|
301a0b687a | ||
|
|
b57a899634 | ||
|
|
2d6a3f83eb | ||
|
|
00e841ad3f | ||
|
|
c527b3d6d0 | ||
|
|
1e6ebab2b2 | ||
|
|
17a0c63abe | ||
|
|
47cb1826a8 | ||
|
|
8595e74566 | ||
|
|
0d0d60600d | ||
|
|
700e1edbb4 | ||
|
|
15fa18411a | ||
|
|
3f4df6f16b | ||
|
|
0f34a64e51 | ||
|
|
7b7c741ba2 | ||
|
|
8f51e33fe0 | ||
|
|
965e30a2d2 | ||
|
|
334d41bd9a | ||
|
|
eb400ab1e9 | ||
|
|
bbcfaa7808 | ||
|
|
6bce5539f7 | ||
|
|
0e03284c95 | ||
|
|
4687fa9669 | ||
|
|
bbae121fec | ||
|
|
033a920746 | ||
|
|
0d24c7f9be | ||
|
|
00f059ee91 |
@@ -23,4 +23,4 @@ VAPID_PUBLIC_KEY=
|
|||||||
|
|
||||||
# ── AI agent (optional in dev; Asistente won't work without it) ──────────────
|
# ── AI agent (optional in dev; Asistente won't work without it) ──────────────
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
AGENT_MODEL=gpt-5.4-mini
|
AGENT_MODEL=gpt-5.6-luna
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -16,3 +16,8 @@ tech_docs/
|
|||||||
.claude/
|
.claude/
|
||||||
.venv/
|
.venv/
|
||||||
frontend/openapi.json
|
frontend/openapi.json
|
||||||
|
|
||||||
|
# playwright (demo artifacts are generated, not committed)
|
||||||
|
frontend/test-results/
|
||||||
|
frontend/playwright-report/
|
||||||
|
frontend/e2e-docs/
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""track agui run id in chat logs
|
||||||
|
|
||||||
|
Revision ID: 10f4a4f71964
|
||||||
|
Revises: ecb99a158fbf
|
||||||
|
Create Date: 2026-07-14 22:32:16.771655
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '10f4a4f71964'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'ecb99a158fbf'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('chatrunlog', sa.Column('agui_run_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
op.create_index(op.f('ix_chatrunlog_agui_run_id'), 'chatrunlog', ['agui_run_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_agui_run_id'), table_name='chatrunlog')
|
||||||
|
op.drop_column('chatrunlog', 'agui_run_id')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""chat thread snapshot
|
||||||
|
|
||||||
|
Revision ID: 794baf50635b
|
||||||
|
Revises: 961802a2f50d
|
||||||
|
Create Date: 2026-07-04 17:49:14.914103
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '794baf50635b'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '961802a2f50d'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('chatthreadsnapshot',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('scope', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('messages', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='[]', nullable=False),
|
||||||
|
sa.Column('state', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
|
||||||
|
sa.Column('interrupt', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('scope', 'thread_id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chatthreadsnapshot_scope'), 'chatthreadsnapshot', ['scope'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_chatthreadsnapshot_scope'), table_name='chatthreadsnapshot')
|
||||||
|
op.drop_table('chatthreadsnapshot')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""record chat client details
|
||||||
|
|
||||||
|
Revision ID: 86104b4ff6ae
|
||||||
|
Revises: 10f4a4f71964
|
||||||
|
Create Date: 2026-07-14 22:54:55.519380
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '86104b4ff6ae'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '10f4a4f71964'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('chatrunlog', sa.Column('client_ip', sa.Text(), nullable=True))
|
||||||
|
op.add_column('chatrunlog', sa.Column('user_agent', sa.Text(), nullable=True))
|
||||||
|
op.add_column('chatrunlog', sa.Column('browser', sa.Text(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('chatrunlog', 'browser')
|
||||||
|
op.drop_column('chatrunlog', 'user_agent')
|
||||||
|
op.drop_column('chatrunlog', 'client_ip')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""add chat run audit logs
|
||||||
|
|
||||||
|
Revision ID: ecb99a158fbf
|
||||||
|
Revises: 794baf50635b
|
||||||
|
Create Date: 2026-07-14 22:22:06.035643
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'ecb99a158fbf'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '794baf50635b'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('chatrunlog',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('request_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('requested_thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column('response_thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column('model', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('user_prompt', sa.Text(), nullable=True),
|
||||||
|
sa.Column('tool_calls', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='[]', nullable=False),
|
||||||
|
sa.Column('assistant_response', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('started_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chatrunlog_expires_at'), 'chatrunlog', ['expires_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chatrunlog_request_id'), 'chatrunlog', ['request_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_chatrunlog_requested_thread_id'), 'chatrunlog', ['requested_thread_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chatrunlog_response_thread_id'), 'chatrunlog', ['response_thread_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chatrunlog_status'), 'chatrunlog', ['status'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_status'), table_name='chatrunlog')
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_response_thread_id'), table_name='chatrunlog')
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_requested_thread_id'), table_name='chatrunlog')
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_request_id'), table_name='chatrunlog')
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_expires_at'), table_name='chatrunlog')
|
||||||
|
op.drop_table('chatrunlog')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from agent_framework import Agent
|
from agent_framework import Agent
|
||||||
from agent_framework.openai import OpenAIChatCompletionClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.agent.tools import TOOLS
|
from app.agent.tools import TOOLS
|
||||||
@@ -22,19 +20,46 @@ Context you can rely on:
|
|||||||
following month. When the user says "this month" or "last month" without
|
following month. When the user says "this month" or "last month" without
|
||||||
qualifiers, assume they mean the calendar month unless they mention
|
qualifiers, assume they mean the calendar month unless they mention
|
||||||
"cycle", "corte", or their credit card.
|
"cycle", "corte", or their credit card.
|
||||||
- Today's date is {today}. Use it when the user says "this month", "last
|
- You do NOT have a reliable clock, and the server runs in UTC while the
|
||||||
month", "last year", etc.
|
user may be anywhere (their browser reports their timezone per request).
|
||||||
|
Whenever the question involves a relative date — "hoy", "ayer", "este
|
||||||
|
mes", "este ciclo", "el año pasado" — call get_current_date FIRST and
|
||||||
|
derive ranges from its answer.
|
||||||
- Amounts are stored as raw numbers in their native currency (see `currency`
|
- Amounts are stored as raw numbers in their native currency (see `currency`
|
||||||
field on transactions/accounts). Tools that return `total_crc` are already
|
field on transactions/accounts). Tools that return `total_crc` are already
|
||||||
converted; tools that return per-transaction amounts are NOT.
|
converted; tools that return per-transaction amounts are NOT.
|
||||||
|
|
||||||
How to answer:
|
How to answer:
|
||||||
- ALWAYS call a tool to get data. Do not invent balances, dates or merchants.
|
- ALWAYS call a tool to get data. Do not invent balances, dates or merchants.
|
||||||
|
- Match the tool to the question's time grain: day-scoped questions ("hoy",
|
||||||
|
"ayer", "el martes", a specific date) need get_daily_spending with that
|
||||||
|
day's bounds — cycle or category summaries have NO per-day data, so never
|
||||||
|
answer a day question from them.
|
||||||
|
- For a question about a specific salary deposit or its amount (including
|
||||||
|
"cuánto recibí hoy"), call get_salary_deposits with the relevant date range;
|
||||||
|
get_salary_summary is aggregate-only and cannot answer it.
|
||||||
|
- Answer like an analyst, not a calculator. An aggregate number on its own
|
||||||
|
is not an answer — show what it is made of, right away, without waiting
|
||||||
|
to be asked:
|
||||||
|
· saldo neto / net worth → headline, then the assets-vs-liabilities
|
||||||
|
split and the accounts behind each side (get_accounts + get_net_worth).
|
||||||
|
· "cuánto gasté hoy/ayer/el martes" → the total, then the purchases
|
||||||
|
behind it (get_recent_transactions with the same date bounds:
|
||||||
|
merchant, amount, source).
|
||||||
|
· cycle or category totals → the top components and how much each
|
||||||
|
contributes.
|
||||||
|
- Structure: one headline sentence with the number, a compact markdown
|
||||||
|
table with the breakdown, and — only when the data genuinely supports
|
||||||
|
it — one closing observation (an unusually large item, a comparison to
|
||||||
|
the previous day/cycle). Never pad with filler or generic offers like
|
||||||
|
"si quieres te puedo mostrar…" when you could just show it.
|
||||||
- Call multiple tools in parallel when the question spans domains
|
- Call multiple tools in parallel when the question spans domains
|
||||||
(e.g. net worth + recent transactions).
|
(e.g. net worth + recent transactions).
|
||||||
- Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for
|
- Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for
|
||||||
USD (two decimals), € for EUR (two decimals).
|
USD (two decimals), € for EUR (two decimals).
|
||||||
- When showing lists, prefer markdown tables over prose.
|
- When showing lists or structured data (transactions, breakdowns, funds),
|
||||||
|
format them as clean markdown tables: right-align amounts, include a total
|
||||||
|
row when summing, keep merchant names as-is.
|
||||||
- If a tool returns no data, say so explicitly — do not fill in zeros.
|
- If a tool returns no data, say so explicitly — do not fill in zeros.
|
||||||
- You are read-only in this version. If asked to create, edit or delete
|
- You are read-only in this version. If asked to create, edit or delete
|
||||||
anything, explain that write actions aren't available yet and offer to
|
anything, explain that write actions aren't available yet and offer to
|
||||||
@@ -47,28 +72,32 @@ Generative UI — render tools:
|
|||||||
- When showing spending totals, cycle summaries, or category breakdowns →
|
- When showing spending totals, cycle summaries, or category breakdowns →
|
||||||
call render_spending_summary. Source data: get_cycle_summary (by_source,
|
call render_spending_summary. Source data: get_cycle_summary (by_source,
|
||||||
grand_total_crc) + get_analytics_by_category (by_category).
|
grand_total_crc) + get_analytics_by_category (by_category).
|
||||||
- When showing transaction lists or other structured data →
|
- Do NOT use markdown tables for data render_spending_summary can display.
|
||||||
call render_a2ui in a SEPARATE tool-call step, only after all data-fetching
|
- CRITICAL RULE: When you call render_spending_summary, that tool call MUST
|
||||||
calls have returned. NEVER call render_a2ui in the same batch as any other
|
be the ONLY content in your message. Do NOT include any text content
|
||||||
tool.
|
alongside the tool call — no introduction, no list, no explanation,
|
||||||
- Do NOT use markdown tables for data a render tool can display.
|
nothing. The rendered card IS the complete response. Any text you write in
|
||||||
- CRITICAL RULE: When you call a render tool (render_spending_summary or
|
the same message as a render call will appear as a duplicate below the
|
||||||
render_a2ui), that tool call MUST be the ONLY content in your message.
|
card, which is wrong.
|
||||||
Do NOT include any text content alongside the tool call — no introduction,
|
- When a render_spending_summary tool result ("ok") is already present for
|
||||||
no list, no explanation, nothing. The rendered card IS the complete
|
the current question, the card is already on screen. Do NOT call the tool
|
||||||
response. Any text you write in the same message as a render call will
|
again and do NOT add text — end your response with no further output.
|
||||||
appear as a duplicate below the card, which is wrong.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def build_agent() -> Agent:
|
def build_agent() -> Agent:
|
||||||
client = OpenAIChatCompletionClient(
|
# Use the Responses API for every configured model. It preserves one tool
|
||||||
|
# transport across model changes and supports Luna's tool-result turns.
|
||||||
|
client = OpenAIChatClient(
|
||||||
api_key=settings.OPENAI_API_KEY,
|
api_key=settings.OPENAI_API_KEY,
|
||||||
model=settings.AGENT_MODEL,
|
model=settings.AGENT_MODEL,
|
||||||
)
|
)
|
||||||
return Agent(
|
return Agent(
|
||||||
name="wealthysmart",
|
name="wealthysmart",
|
||||||
instructions=SYSTEM_PROMPT.replace("{today}", date.today().isoformat()),
|
# No date baked in: build_agent() runs once at startup, so anything
|
||||||
|
# substituted here freezes at boot (and in server-UTC). The model
|
||||||
|
# gets "today" from the get_current_date tool instead.
|
||||||
|
instructions=SYSTEM_PROMPT,
|
||||||
client=client,
|
client=client,
|
||||||
tools=TOOLS,
|
tools=TOOLS,
|
||||||
)
|
)
|
||||||
|
|||||||
153
backend/app/agent/chat_audit.py
Normal file
153
backend/app/agent/chat_audit.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Durable diagnostics for individual AG-UI agent runs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextvars
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlmodel import Session, delete, select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import engine
|
||||||
|
from app.models.models import ChatRunLog
|
||||||
|
from app.timeutil import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
CHAT_LOG_RETENTION_DAYS = 90
|
||||||
|
_chat_run_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
|
||||||
|
"chat_run_id", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_chat_run(run_id: int) -> contextvars.Token:
|
||||||
|
return _chat_run_id.set(run_id)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_current_chat_run(token: contextvars.Token) -> None:
|
||||||
|
_chat_run_id.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def current_chat_run_id() -> int | None:
|
||||||
|
return _chat_run_id.get()
|
||||||
|
|
||||||
|
|
||||||
|
def _last_user_prompt(messages: list[dict[str, Any]]) -> str | None:
|
||||||
|
for message in reversed(messages):
|
||||||
|
if message.get("role") == "user" and isinstance(message.get("content"), str):
|
||||||
|
return message["content"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_chat_run(
|
||||||
|
*,
|
||||||
|
request_id: str,
|
||||||
|
agui_run_id: str | None,
|
||||||
|
thread_id: str | None,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
client_ip: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
browser: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
now = utcnow()
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = ChatRunLog(
|
||||||
|
request_id=request_id,
|
||||||
|
agui_run_id=agui_run_id,
|
||||||
|
requested_thread_id=thread_id,
|
||||||
|
model=settings.AGENT_MODEL,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
browser=browser,
|
||||||
|
user_prompt=_last_user_prompt(messages),
|
||||||
|
expires_at=now + timedelta(days=CHAT_LOG_RETENTION_DAYS),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
assert row.id is not None
|
||||||
|
return row.id
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_activity(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
calls: dict[str, dict[str, Any]] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for message in messages:
|
||||||
|
if message.get("role") == "assistant":
|
||||||
|
for call in message.get("toolCalls") or message.get("tool_calls") or []:
|
||||||
|
call_id = str(call.get("id") or "")
|
||||||
|
if not call_id or call_id in calls:
|
||||||
|
continue
|
||||||
|
function = call.get("function") or {}
|
||||||
|
calls[call_id] = {
|
||||||
|
"id": call_id,
|
||||||
|
"name": function.get("name"),
|
||||||
|
"arguments": function.get("arguments"),
|
||||||
|
}
|
||||||
|
order.append(call_id)
|
||||||
|
elif message.get("role") == "tool":
|
||||||
|
call_id = str(message.get("toolCallId") or message.get("tool_call_id") or "")
|
||||||
|
if call_id in calls:
|
||||||
|
calls[call_id]["result"] = message.get("content")
|
||||||
|
if message.get("error") is not None:
|
||||||
|
calls[call_id]["error"] = message["error"]
|
||||||
|
return [calls[call_id] for call_id in order]
|
||||||
|
|
||||||
|
|
||||||
|
def _last_assistant_response(messages: list[dict[str, Any]]) -> str | None:
|
||||||
|
for message in reversed(messages):
|
||||||
|
if message.get("role") == "assistant" and isinstance(message.get("content"), str):
|
||||||
|
return message["content"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_chat_run_in_session(
|
||||||
|
session: Session, *, run_id: int, response_thread_id: str, messages: list[dict[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
row = session.get(ChatRunLog, run_id)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
row.response_thread_id = response_thread_id
|
||||||
|
row.tool_calls = _tool_activity(messages)
|
||||||
|
row.assistant_response = _last_assistant_response(messages)
|
||||||
|
row.status = "completed"
|
||||||
|
row.completed_at = utcnow()
|
||||||
|
session.add(row)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_chat_run_failed(run_id: int, error: str) -> None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.get(ChatRunLog, run_id)
|
||||||
|
if row is None or row.status == "completed":
|
||||||
|
return
|
||||||
|
row.status = "failed"
|
||||||
|
row.error = error[:4000]
|
||||||
|
row.completed_at = utcnow()
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def finish_pending_chat_run(run_id: int) -> None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.get(ChatRunLog, run_id)
|
||||||
|
if row is None or row.status != "started":
|
||||||
|
return
|
||||||
|
row.status = "completed"
|
||||||
|
row.completed_at = utcnow()
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def purge_expired_chat_runs() -> int:
|
||||||
|
with Session(engine) as session:
|
||||||
|
result = session.exec(delete(ChatRunLog).where(ChatRunLog.expires_at < utcnow()))
|
||||||
|
session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_expired_chat_runs_periodically() -> None:
|
||||||
|
"""Purge at startup and then once per day for a bounded audit footprint."""
|
||||||
|
while True:
|
||||||
|
await asyncio.to_thread(purge_expired_chat_runs)
|
||||||
|
await asyncio.sleep(24 * 60 * 60)
|
||||||
194
backend/app/agent/snapshot_store.py
Normal file
194
backend/app/agent/snapshot_store.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""Postgres-backed AG-UI thread snapshot store.
|
||||||
|
|
||||||
|
Implements the AGUIThreadSnapshotStore protocol from agent-framework-ag-ui so
|
||||||
|
the assistant conversation survives page reloads: MAF calls `save` with the
|
||||||
|
full cumulative message list at each run end, and `get` on the next request
|
||||||
|
(server-side context rebuild, or a client hydration run with empty messages).
|
||||||
|
|
||||||
|
Two deliberate choices:
|
||||||
|
- Own sessions per call: MAF saves during SSE streaming, after the
|
||||||
|
agent_auth_and_session middleware has already closed the request-bound
|
||||||
|
ContextVar session. Sync SQLModel work runs in a thread so the event loop
|
||||||
|
keeps streaming.
|
||||||
|
- `save` normalizes assistant `tool_calls` -> `toolCalls`: @ag-ui/client's
|
||||||
|
Zod schema silently strips the snake_case field, so a replayed snapshot
|
||||||
|
would lose tool calls (and the spending-summary card) on the way to the
|
||||||
|
browser. MAF reads both casings back, so storing camelCase is safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework_ag_ui import AGUIThreadSnapshot
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
from app.agent.chat_audit import current_chat_run_id, finalize_chat_run_in_session
|
||||||
|
from app.models.models import ChatThreadSnapshot
|
||||||
|
from app.timeutil import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_message(msg: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = dict(msg)
|
||||||
|
tool_calls = out.pop("tool_calls", None)
|
||||||
|
if tool_calls is None:
|
||||||
|
tool_calls = out.pop("toolCalls", None)
|
||||||
|
else:
|
||||||
|
out.pop("toolCalls", None)
|
||||||
|
# MAF writes tool_calls: null on plain-text assistant messages; only a
|
||||||
|
# non-empty list is meaningful downstream.
|
||||||
|
if isinstance(tool_calls, list) and tool_calls:
|
||||||
|
out["toolCalls"] = tool_calls
|
||||||
|
if "tool_call_id" in out:
|
||||||
|
out.setdefault("toolCallId", out["tool_call_id"])
|
||||||
|
del out["tool_call_id"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Sanitize the cumulative snapshot before storing it.
|
||||||
|
|
||||||
|
MAF's history merge re-records the resent suffix of multi-run turns
|
||||||
|
(frontend-tool roundtrips) without deduplicating, and the model
|
||||||
|
occasionally re-issues an identical render tool call on the follow-up
|
||||||
|
run. Left as-is, the snapshot grows duplicates that replay as repeated
|
||||||
|
bubbles/cards after a reload.
|
||||||
|
"""
|
||||||
|
# Pass 1: drop BFF-injected ctx-* system messages, duplicate message
|
||||||
|
# ids, and repeated identical tool calls (same function + arguments)
|
||||||
|
# within one user turn.
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
turn_calls: set[tuple[Any, Any]] = set()
|
||||||
|
kept_call_ids: set[str] = set()
|
||||||
|
first_pass: list[dict[str, Any]] = []
|
||||||
|
for raw in messages:
|
||||||
|
m = _normalize_message(raw)
|
||||||
|
mid = str(m.get("id", ""))
|
||||||
|
if mid.startswith("ctx-"):
|
||||||
|
continue
|
||||||
|
if mid and mid in seen_ids:
|
||||||
|
continue
|
||||||
|
role = m.get("role")
|
||||||
|
if role == "user":
|
||||||
|
turn_calls = set()
|
||||||
|
if role == "assistant" and isinstance(m.get("toolCalls"), list):
|
||||||
|
kept: list[dict[str, Any]] = []
|
||||||
|
for tc in m["toolCalls"]:
|
||||||
|
fn = tc.get("function") or {}
|
||||||
|
key = (fn.get("name"), fn.get("arguments"))
|
||||||
|
if key in turn_calls:
|
||||||
|
continue
|
||||||
|
turn_calls.add(key)
|
||||||
|
kept.append(tc)
|
||||||
|
if tc.get("id"):
|
||||||
|
kept_call_ids.add(str(tc["id"]))
|
||||||
|
if kept:
|
||||||
|
m["toolCalls"] = kept
|
||||||
|
else:
|
||||||
|
m.pop("toolCalls", None)
|
||||||
|
if not m.get("content"):
|
||||||
|
continue # nothing left worth replaying
|
||||||
|
if mid:
|
||||||
|
seen_ids.add(mid)
|
||||||
|
first_pass.append(m)
|
||||||
|
|
||||||
|
# Pass 2: one tool result per surviving call id. Results whose call was
|
||||||
|
# deduplicated away must go too — an orphan tool message in the stored
|
||||||
|
# history would 400 the next OpenAI request when MAF rebuilds context.
|
||||||
|
answered: set[str] = set()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for m in first_pass:
|
||||||
|
if m.get("role") == "tool":
|
||||||
|
tcid = str(m.get("toolCallId") or "")
|
||||||
|
if tcid and (tcid not in kept_call_ids or tcid in answered):
|
||||||
|
continue
|
||||||
|
if tcid:
|
||||||
|
answered.add(tcid)
|
||||||
|
out.append(m)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresSnapshotStore:
|
||||||
|
"""One upserted row per (scope, thread_id); latest snapshot only."""
|
||||||
|
|
||||||
|
async def save(
|
||||||
|
self, *, scope: str, thread_id: str, snapshot: AGUIThreadSnapshot
|
||||||
|
) -> None:
|
||||||
|
def _save() -> None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.exec(
|
||||||
|
select(ChatThreadSnapshot).where(
|
||||||
|
ChatThreadSnapshot.scope == scope,
|
||||||
|
ChatThreadSnapshot.thread_id == thread_id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
row = ChatThreadSnapshot(scope=scope, thread_id=thread_id)
|
||||||
|
row.messages = _clean_messages(snapshot.messages)
|
||||||
|
row.state = snapshot.state
|
||||||
|
row.interrupt = snapshot.interrupt
|
||||||
|
row.updated_at = utcnow()
|
||||||
|
session.add(row)
|
||||||
|
run_id = current_chat_run_id()
|
||||||
|
if run_id is not None:
|
||||||
|
finalize_chat_run_in_session(
|
||||||
|
session,
|
||||||
|
run_id=run_id,
|
||||||
|
response_thread_id=thread_id,
|
||||||
|
messages=row.messages,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
await asyncio.to_thread(_save)
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self, *, scope: str, thread_id: str
|
||||||
|
) -> AGUIThreadSnapshot | None:
|
||||||
|
def _get() -> AGUIThreadSnapshot | None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.exec(
|
||||||
|
select(ChatThreadSnapshot).where(
|
||||||
|
ChatThreadSnapshot.scope == scope,
|
||||||
|
ChatThreadSnapshot.thread_id == thread_id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return AGUIThreadSnapshot(
|
||||||
|
messages=row.messages or [],
|
||||||
|
state=row.state,
|
||||||
|
interrupt=row.interrupt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_get)
|
||||||
|
|
||||||
|
async def delete(self, *, scope: str, thread_id: str) -> bool:
|
||||||
|
def _delete() -> bool:
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.exec(
|
||||||
|
select(ChatThreadSnapshot).where(
|
||||||
|
ChatThreadSnapshot.scope == scope,
|
||||||
|
ChatThreadSnapshot.thread_id == thread_id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_delete)
|
||||||
|
|
||||||
|
async def clear(self, *, scope: str | None = None) -> None:
|
||||||
|
def _clear() -> None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
stmt = select(ChatThreadSnapshot)
|
||||||
|
if scope is not None:
|
||||||
|
stmt = stmt.where(ChatThreadSnapshot.scope == scope)
|
||||||
|
for row in session.exec(stmt).all():
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
await asyncio.to_thread(_clear)
|
||||||
@@ -13,7 +13,7 @@ write tool requires an explicit user-confirmation step in the UI.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextvars
|
import contextvars
|
||||||
from datetime import datetime
|
from datetime import datetime, time, timedelta
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@@ -35,17 +35,20 @@ from app.models.models import (
|
|||||||
from app.services.budget_projection import (
|
from app.services.budget_projection import (
|
||||||
MAX_YEAR,
|
MAX_YEAR,
|
||||||
MIN_YEAR,
|
MIN_YEAR,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
compute_monthly_projection,
|
compute_monthly_projection,
|
||||||
compute_yearly_projection_with_cumulative,
|
compute_yearly_projection_with_cumulative,
|
||||||
get_cycle_range,
|
get_cycle_range,
|
||||||
)
|
)
|
||||||
from app.services import exchange_rate as fx
|
from app.services import exchange_rate as fx
|
||||||
|
from app.timeutil import client_tz, today_client
|
||||||
from app.services.exchange_rate import (
|
from app.services.exchange_rate import (
|
||||||
get_converted_amount_expr,
|
get_converted_amount_expr,
|
||||||
get_current_rate,
|
get_current_rate,
|
||||||
)
|
)
|
||||||
|
|
||||||
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
|
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
|
||||||
|
SALARY_TRANSACTION_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO)
|
||||||
|
|
||||||
|
|
||||||
def set_session(session: Session) -> contextvars.Token:
|
def set_session(session: Session) -> contextvars.Token:
|
||||||
@@ -140,7 +143,11 @@ def get_recent_transactions(
|
|||||||
q = select(Transaction).where(
|
q = select(Transaction).where(
|
||||||
col(Transaction.transaction_type).notin_(
|
col(Transaction.transaction_type).notin_(
|
||||||
[TransactionType.SALARY, TransactionType.DEPOSITO]
|
[TransactionType.SALARY, TransactionType.DEPOSITO]
|
||||||
)
|
),
|
||||||
|
# Tasa Cero generates future-dated cuotas; "recent" means already
|
||||||
|
# billed (same rule as /transactions/recent). Bound is end of the
|
||||||
|
# user's today in their request timezone (CR fallback).
|
||||||
|
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
|
||||||
)
|
)
|
||||||
if source:
|
if source:
|
||||||
q = q.where(Transaction.source == TransactionSource(source))
|
q = q.where(Transaction.source == TransactionSource(source))
|
||||||
@@ -333,7 +340,7 @@ def get_salary_summary() -> dict:
|
|||||||
func.count(),
|
func.count(),
|
||||||
func.coalesce(func.sum(amount_crc), 0),
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
func.max(Transaction.date),
|
func.max(Transaction.date),
|
||||||
).where(Transaction.transaction_type == TransactionType.SALARY)
|
).where(col(Transaction.transaction_type).in_(SALARY_TRANSACTION_TYPES))
|
||||||
).first()
|
).first()
|
||||||
count = row[0] if row else 0
|
count = row[0] if row else 0
|
||||||
total = float(row[1]) if row else 0.0
|
total = float(row[1]) if row else 0.0
|
||||||
@@ -341,6 +348,44 @@ def get_salary_summary() -> dict:
|
|||||||
return {"count": count, "total_crc": total, "latest_date": latest}
|
return {"count": count, "total_crc": total, "latest_date": latest}
|
||||||
|
|
||||||
|
|
||||||
|
def get_salary_deposits(
|
||||||
|
limit: Annotated[int, Field(ge=1, le=100, description="How many deposits to return")] = 20,
|
||||||
|
start_date: Annotated[
|
||||||
|
Optional[str], Field(description="ISO date lower bound, inclusive")
|
||||||
|
] = None,
|
||||||
|
end_date: Annotated[
|
||||||
|
Optional[str], Field(description="ISO date upper bound, exclusive")
|
||||||
|
] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Individual salary deposits, newest first. Use this for questions about
|
||||||
|
a specific salary or a day/range (for example, "cuánto recibí hoy").
|
||||||
|
Amounts remain in their recorded currency; use the currency field when
|
||||||
|
presenting them."""
|
||||||
|
q = select(Transaction).where(
|
||||||
|
col(Transaction.transaction_type).in_(SALARY_TRANSACTION_TYPES)
|
||||||
|
)
|
||||||
|
if start_date:
|
||||||
|
q = q.where(Transaction.date >= datetime.fromisoformat(start_date))
|
||||||
|
if end_date:
|
||||||
|
q = q.where(Transaction.date < datetime.fromisoformat(end_date))
|
||||||
|
q = q.order_by(col(Transaction.date).desc(), col(Transaction.id).desc()).limit(limit)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"date": t.date.isoformat(),
|
||||||
|
"amount": float(t.amount),
|
||||||
|
"currency": t.currency.value,
|
||||||
|
"merchant": t.merchant,
|
||||||
|
"source": t.source.value,
|
||||||
|
"bank": t.bank.value,
|
||||||
|
"transaction_type": t.transaction_type.value,
|
||||||
|
"reference": t.reference,
|
||||||
|
"notes": t.notes,
|
||||||
|
}
|
||||||
|
for t in _s().exec(q).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_municipal_receipts(
|
def get_municipal_receipts(
|
||||||
limit: Annotated[int, Field(ge=1, le=50)] = 12,
|
limit: Annotated[int, Field(ge=1, le=50)] = 12,
|
||||||
offset: Annotated[int, Field(ge=0, description="Skip the N most recent")] = 0,
|
offset: Annotated[int, Field(ge=0, description="Skip the N most recent")] = 0,
|
||||||
@@ -491,7 +536,68 @@ def list_categories() -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
# Registered with the agent in agent.py
|
# Registered with the agent in agent.py
|
||||||
|
def get_daily_spending(
|
||||||
|
start_date: Annotated[str, Field(description="ISO date lower bound, inclusive")],
|
||||||
|
end_date: Annotated[str, Field(description="ISO date upper bound, exclusive")],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Spending per day in CRC (converted), COMPRA only, excluding future
|
||||||
|
Tasa Cero cuotas and installment anchors. THE tool for day-scoped
|
||||||
|
questions: 'cuánto gasté hoy / ayer / el martes'. Days with no spending
|
||||||
|
are simply absent from the result."""
|
||||||
|
session = _s()
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
rows = session.exec(
|
||||||
|
select(
|
||||||
|
func.date(Transaction.date).label("day"),
|
||||||
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
|
func.count(),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
|
Transaction.date >= datetime.fromisoformat(start_date),
|
||||||
|
Transaction.date < datetime.fromisoformat(end_date),
|
||||||
|
# Future-dated Tasa Cero cuotas are not money already spent.
|
||||||
|
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
|
||||||
|
)
|
||||||
|
.group_by(func.date(Transaction.date))
|
||||||
|
.order_by(func.date(Transaction.date))
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
{"date": str(day), "total_crc": float(total), "count": count}
|
||||||
|
for day, total, count in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_date() -> dict:
|
||||||
|
"""Today's date in the user's own timezone (sent by their browser;
|
||||||
|
Costa Rica fallback) and the active credit-card billing cycle. ALWAYS
|
||||||
|
call this before resolving any relative date reference — 'hoy', 'ayer',
|
||||||
|
'este mes', 'este ciclo', 'el ciclo pasado' — the server clock and your
|
||||||
|
own assumptions about today are unreliable."""
|
||||||
|
today = today_client()
|
||||||
|
# get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before
|
||||||
|
# the 18th we are still in the cycle that started last month.
|
||||||
|
if today.day >= 18:
|
||||||
|
cycle_year, cycle_month = today.year, today.month
|
||||||
|
elif today.month == 1:
|
||||||
|
cycle_year, cycle_month = today.year - 1, 12
|
||||||
|
else:
|
||||||
|
cycle_year, cycle_month = today.year, today.month - 1
|
||||||
|
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||||
|
return {
|
||||||
|
"date": today.isoformat(),
|
||||||
|
"weekday": today.strftime("%A"),
|
||||||
|
"timezone": str(client_tz()),
|
||||||
|
"cycle_year": cycle_year,
|
||||||
|
"cycle_month": cycle_month,
|
||||||
|
"cycle_range": [start.date().isoformat(), end.date().isoformat()],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
TOOLS = [
|
TOOLS = [
|
||||||
|
get_current_date,
|
||||||
|
get_daily_spending,
|
||||||
get_accounts,
|
get_accounts,
|
||||||
get_net_worth,
|
get_net_worth,
|
||||||
get_recent_transactions,
|
get_recent_transactions,
|
||||||
@@ -500,6 +606,7 @@ TOOLS = [
|
|||||||
list_recurring_items,
|
list_recurring_items,
|
||||||
get_pension_snapshots,
|
get_pension_snapshots,
|
||||||
get_salary_summary,
|
get_salary_summary,
|
||||||
|
get_salary_deposits,
|
||||||
get_municipal_receipts,
|
get_municipal_receipts,
|
||||||
get_analytics_by_category,
|
get_analytics_by_category,
|
||||||
get_monthly_trend,
|
get_monthly_trend,
|
||||||
|
|||||||
41
backend/app/api/v1/endpoints/chat.py
Normal file
41
backend/app/api/v1/endpoints/chat.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Assistant chat thread management.
|
||||||
|
|
||||||
|
The conversation itself is persisted by the AG-UI snapshot store
|
||||||
|
(app/agent/snapshot_store.py) under a single ("default", "main") thread;
|
||||||
|
this router only exposes the reset used by "Nueva conversación".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models.models import ChatThreadSnapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||||
|
|
||||||
|
CHAT_SCOPE = "default"
|
||||||
|
CHAT_THREAD_ID = "main"
|
||||||
|
|
||||||
|
|
||||||
|
class ClearThreadResponse(BaseModel):
|
||||||
|
cleared: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/thread", response_model=ClearThreadResponse)
|
||||||
|
def clear_thread(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = session.exec(
|
||||||
|
select(ChatThreadSnapshot).where(
|
||||||
|
ChatThreadSnapshot.scope == CHAT_SCOPE,
|
||||||
|
ChatThreadSnapshot.thread_id == CHAT_THREAD_ID,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
return {"cleared": False}
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
|
return {"cleared": True}
|
||||||
@@ -6,6 +6,7 @@ from app.api.v1.endpoints import (
|
|||||||
auth,
|
auth,
|
||||||
budget,
|
budget,
|
||||||
categories,
|
categories,
|
||||||
|
chat,
|
||||||
exchange_rate,
|
exchange_rate,
|
||||||
import_transactions,
|
import_transactions,
|
||||||
installments,
|
installments,
|
||||||
@@ -38,3 +39,4 @@ api_router.include_router(pensions.router)
|
|||||||
api_router.include_router(municipal_receipts.router)
|
api_router.include_router(municipal_receipts.router)
|
||||||
api_router.include_router(savings_accrual.router)
|
api_router.include_router(savings_accrual.router)
|
||||||
api_router.include_router(sync_status.router)
|
api_router.include_router(sync_status.router)
|
||||||
|
api_router.include_router(chat.router)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class Settings(BaseSettings):
|
|||||||
VAPID_PUBLIC_KEY: str = ""
|
VAPID_PUBLIC_KEY: str = ""
|
||||||
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
|
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
|
||||||
OPENAI_API_KEY: str = ""
|
OPENAI_API_KEY: str = ""
|
||||||
AGENT_MODEL: str = "gpt-5.4-mini"
|
AGENT_MODEL: str = "gpt-5.6-luna"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins_list(self) -> list[str]:
|
def cors_origins_list(self) -> list[str]:
|
||||||
|
|||||||
31
backend/app/logging.py
Normal file
31
backend/app/logging.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Structured, redacted application logging for container stdout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class _JSONEncoder(json.JSONEncoder):
|
||||||
|
def default(self, value: Any) -> Any:
|
||||||
|
if isinstance(value, (datetime, date)):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
return value.value
|
||||||
|
return super().default(value)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_structured_logging() -> None:
|
||||||
|
"""Reserved lifecycle hook for the structured stdout audit channel."""
|
||||||
|
|
||||||
|
|
||||||
|
def log_event(event: str, **fields: Any) -> None:
|
||||||
|
"""Write a single JSON line; callers must not pass credentials or bodies."""
|
||||||
|
# stdout is Docker's durable capture point. `flush=True` also makes this
|
||||||
|
# visible immediately in `docker compose logs` during an active SSE run.
|
||||||
|
print(
|
||||||
|
json.dumps({"event": event, **fields}, cls=_JSONEncoder, separators=(",", ":")),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
from time import perf_counter
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -11,6 +12,15 @@ from jose import JWTError, jwt
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.agent.agent import build_agent
|
from app.agent.agent import build_agent
|
||||||
|
from app.agent.chat_audit import (
|
||||||
|
create_chat_run,
|
||||||
|
finish_pending_chat_run,
|
||||||
|
mark_chat_run_failed,
|
||||||
|
purge_expired_chat_runs_periodically,
|
||||||
|
reset_current_chat_run,
|
||||||
|
set_current_chat_run,
|
||||||
|
)
|
||||||
|
from app.agent.snapshot_store import PostgresSnapshotStore
|
||||||
from app.agent.tools import reset_session, set_session
|
from app.agent.tools import reset_session, set_session
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.auth import (
|
from app.auth import (
|
||||||
@@ -22,10 +32,47 @@ from app.auth import (
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_session, run_alembic_upgrade
|
from app.db import get_session, run_alembic_upgrade
|
||||||
from app.seed import seed_db
|
from app.seed import seed_db
|
||||||
|
from app.timeutil import reset_client_timezone, set_client_timezone
|
||||||
from app.services.exchange_rate import refresh_rates_periodically
|
from app.services.exchange_rate import refresh_rates_periodically
|
||||||
|
from app.logging import configure_structured_logging, log_event
|
||||||
|
|
||||||
|
|
||||||
AGENT_PATH = "/api/v1/agent/agui"
|
AGENT_PATH = "/api/v1/agent/agui"
|
||||||
|
MAX_USER_AGENT_LENGTH = 512
|
||||||
|
|
||||||
|
|
||||||
|
def _request_client_details(request: Request) -> tuple[str, str | None, str | None]:
|
||||||
|
"""Return the browser client details propagated through the frontend BFF.
|
||||||
|
|
||||||
|
Production nginx-proxy supplies X-Real-IP and the frontend forwards it to
|
||||||
|
FastAPI. For direct local development, fall back to X-Forwarded-For and
|
||||||
|
then the socket peer. The BFF is the only production path to the backend,
|
||||||
|
which is not publicly exposed.
|
||||||
|
"""
|
||||||
|
client_ip = request.headers.get("x-real-ip", "").strip()
|
||||||
|
if not client_ip:
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
client_ip = forwarded.split(",", 1)[0].strip()
|
||||||
|
if not client_ip:
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
user_agent = request.headers.get("user-agent", "").strip()[:MAX_USER_AGENT_LENGTH] or None
|
||||||
|
if not user_agent:
|
||||||
|
return client_ip, None, None
|
||||||
|
ua = user_agent.lower()
|
||||||
|
if "edg/" in ua or "edgios/" in ua:
|
||||||
|
browser = "Edge"
|
||||||
|
elif "opr/" in ua or "opera" in ua:
|
||||||
|
browser = "Opera"
|
||||||
|
elif "firefox/" in ua or "fxios/" in ua:
|
||||||
|
browser = "Firefox"
|
||||||
|
elif "chrome/" in ua or "crios/" in ua:
|
||||||
|
browser = "Chrome"
|
||||||
|
elif "safari/" in ua:
|
||||||
|
browser = "Safari"
|
||||||
|
else:
|
||||||
|
browser = "Other"
|
||||||
|
return client_ip, user_agent, browser
|
||||||
|
|
||||||
|
|
||||||
def _pair_orphan_tool_calls(messages: list) -> list:
|
def _pair_orphan_tool_calls(messages: list) -> list:
|
||||||
@@ -63,19 +110,60 @@ def _pair_orphan_tool_calls(messages: list) -> list:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_stale_client_history(session, thread_id, messages: list) -> list:
|
||||||
|
"""Conversation history is server-owned (the AG-UI snapshot store).
|
||||||
|
|
||||||
|
A request that carries assistant/tool history for a thread with NO
|
||||||
|
stored snapshot comes from a stale client — typically a second tab that
|
||||||
|
was open when 'Nueva conversación' cleared the thread. Trusting it
|
||||||
|
would re-persist the cleared conversation ('chunks' resurrecting after
|
||||||
|
every clear). Keep only the last user turn; MAF starts the thread
|
||||||
|
fresh from there.
|
||||||
|
"""
|
||||||
|
if not thread_id or not messages:
|
||||||
|
return messages
|
||||||
|
has_history = any(m.get("role") in ("assistant", "tool") for m in messages)
|
||||||
|
if not has_history:
|
||||||
|
return messages
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.models.models import ChatThreadSnapshot
|
||||||
|
|
||||||
|
row = session.exec(
|
||||||
|
select(ChatThreadSnapshot.id).where(
|
||||||
|
ChatThreadSnapshot.thread_id == str(thread_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is not None:
|
||||||
|
return messages # thread exists; history is legitimate
|
||||||
|
last_user = None
|
||||||
|
for i in range(len(messages) - 1, -1, -1):
|
||||||
|
if messages[i].get("role") == "user":
|
||||||
|
last_user = i
|
||||||
|
break
|
||||||
|
return messages[last_user:] if last_user is not None else []
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
configure_structured_logging()
|
||||||
run_alembic_upgrade()
|
run_alembic_upgrade()
|
||||||
seed_db()
|
seed_db()
|
||||||
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
|
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
|
||||||
|
chat_audit_cleanup_task = asyncio.create_task(purge_expired_chat_runs_periodically())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
rate_refresh_task.cancel()
|
rate_refresh_task.cancel()
|
||||||
|
chat_audit_cleanup_task.cancel()
|
||||||
try:
|
try:
|
||||||
await rate_refresh_task
|
await rate_refresh_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
await chat_audit_cleanup_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
||||||
@@ -89,6 +177,67 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def log_api_request(request: Request, call_next):
|
||||||
|
"""Emit redacted, one-line diagnostics for API requests only.
|
||||||
|
|
||||||
|
This deliberately records no request/response bodies, headers, tokens,
|
||||||
|
passwords, or financial payloads. Full assistant content is retained only
|
||||||
|
in the bounded chat-run audit table.
|
||||||
|
"""
|
||||||
|
request_id = uuid.uuid4().hex
|
||||||
|
request.state.request_id = request_id
|
||||||
|
client_ip, user_agent, browser = _request_client_details(request)
|
||||||
|
request.state.client_ip = client_ip
|
||||||
|
request.state.user_agent = user_agent
|
||||||
|
request.state.browser = browser
|
||||||
|
started = perf_counter()
|
||||||
|
status_code = 500
|
||||||
|
is_logged_api = request.url.path.startswith("/api/") and request.url.path != "/api/health"
|
||||||
|
|
||||||
|
def emit(error_type: str | None = None) -> None:
|
||||||
|
if not is_logged_api:
|
||||||
|
return
|
||||||
|
route = request.scope.get("route")
|
||||||
|
log_event(
|
||||||
|
"api_request",
|
||||||
|
request_id=request_id,
|
||||||
|
method=request.method,
|
||||||
|
path=getattr(route, "path", request.url.path),
|
||||||
|
status_code=status_code,
|
||||||
|
duration_ms=round((perf_counter() - started) * 1000, 1),
|
||||||
|
error_type=error_type,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
browser=browser,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
status_code = response.status_code
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
original_iterator = getattr(response, "body_iterator", None)
|
||||||
|
if original_iterator is None:
|
||||||
|
emit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def logged_stream():
|
||||||
|
error_type: str | None = None
|
||||||
|
try:
|
||||||
|
async for chunk in original_iterator:
|
||||||
|
yield chunk
|
||||||
|
except Exception as exc:
|
||||||
|
error_type = type(exc).__name__
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
emit(error_type)
|
||||||
|
|
||||||
|
response.body_iterator = logged_stream()
|
||||||
|
return response
|
||||||
|
except Exception as exc:
|
||||||
|
emit(type(exc).__name__)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def agent_auth_and_session(request: Request, call_next):
|
async def agent_auth_and_session(request: Request, call_next):
|
||||||
"""For the AG-UI route, validate the JWT, repair message history, and
|
"""For the AG-UI route, validate the JWT, repair message history, and
|
||||||
@@ -119,25 +268,94 @@ async def agent_auth_and_session(request: Request, call_next):
|
|||||||
except JWTError:
|
except JWTError:
|
||||||
return Response(status_code=401, content="Invalid token")
|
return Response(status_code=401, content="Invalid token")
|
||||||
|
|
||||||
|
session_gen = get_session()
|
||||||
|
session = next(session_gen)
|
||||||
|
token_var = set_session(session)
|
||||||
|
|
||||||
|
chat_run_id: int | None = None
|
||||||
# Repair orphan tool_calls before the MAF agent sees the message history.
|
# Repair orphan tool_calls before the MAF agent sees the message history.
|
||||||
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
|
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
|
||||||
raw = await request.body()
|
raw = await request.body()
|
||||||
try:
|
try:
|
||||||
body = json.loads(raw)
|
body = json.loads(raw)
|
||||||
if isinstance(body.get("messages"), list):
|
if isinstance(body.get("messages"), list):
|
||||||
|
body["messages"] = _drop_stale_client_history(
|
||||||
|
session,
|
||||||
|
body.get("threadId") or body.get("thread_id"),
|
||||||
|
body["messages"],
|
||||||
|
)
|
||||||
body["messages"] = _pair_orphan_tool_calls(body["messages"])
|
body["messages"] = _pair_orphan_tool_calls(body["messages"])
|
||||||
raw = json.dumps(body).encode()
|
raw = json.dumps(body).encode()
|
||||||
except Exception:
|
log_event(
|
||||||
pass
|
"agent_request",
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
agui_run_id=body.get("runId"),
|
||||||
|
thread_id=body.get("threadId") or body.get("thread_id"),
|
||||||
|
message_count=len(body.get("messages") or []),
|
||||||
|
has_user_message=any(
|
||||||
|
message.get("role") == "user" for message in body.get("messages") or []
|
||||||
|
),
|
||||||
|
client_ip=request.state.client_ip,
|
||||||
|
user_agent=request.state.user_agent,
|
||||||
|
browser=request.state.browser,
|
||||||
|
)
|
||||||
|
# The BFF removes its `method: agent/run` envelope before
|
||||||
|
# forwarding to MAF. A non-empty user message is the reliable
|
||||||
|
# backend-side distinction between an agent run and hydration.
|
||||||
|
if any(message.get("role") == "user" for message in body.get("messages") or []):
|
||||||
|
chat_run_id = create_chat_run(
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
agui_run_id=body.get("runId"),
|
||||||
|
thread_id=body.get("threadId") or body.get("thread_id"),
|
||||||
|
messages=body.get("messages") or [],
|
||||||
|
client_ip=request.state.client_ip,
|
||||||
|
user_agent=request.state.user_agent,
|
||||||
|
browser=request.state.browser,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Parsing/repair must not prevent the agent endpoint from serving;
|
||||||
|
# the access log will still capture a resulting error response.
|
||||||
|
log_event(
|
||||||
|
"chat_audit_start_failed",
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
)
|
||||||
|
body = None
|
||||||
# Starlette caches the body; replace it so call_next sees the fixed bytes.
|
# Starlette caches the body; replace it so call_next sees the fixed bytes.
|
||||||
request._body = raw # type: ignore[attr-defined]
|
request._body = raw # type: ignore[attr-defined]
|
||||||
|
# Browser's IANA timezone, forwarded by the BFF — lets tools resolve
|
||||||
session_gen = get_session()
|
# "today" wherever the user is (falls back to Costa Rica).
|
||||||
session = next(session_gen)
|
tz_token = set_client_timezone(request.headers.get("x-client-timezone"))
|
||||||
token_var = set_session(session)
|
audit_token = set_current_chat_run(chat_run_id) if chat_run_id is not None else None
|
||||||
try:
|
try:
|
||||||
return await call_next(request)
|
response = await call_next(request)
|
||||||
|
if chat_run_id is None:
|
||||||
|
return response
|
||||||
|
|
||||||
|
original_iterator = response.body_iterator
|
||||||
|
|
||||||
|
async def audit_stream():
|
||||||
|
try:
|
||||||
|
async for chunk in original_iterator:
|
||||||
|
yield chunk
|
||||||
|
except Exception as exc:
|
||||||
|
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# MAF normally finalizes via PostgresSnapshotStore.save(). If
|
||||||
|
# it emitted no snapshot, do not leave a permanently-open row.
|
||||||
|
finish_pending_chat_run(chat_run_id)
|
||||||
|
|
||||||
|
response.body_iterator = audit_stream()
|
||||||
|
return response
|
||||||
|
except Exception as exc:
|
||||||
|
if chat_run_id is not None:
|
||||||
|
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
if audit_token is not None:
|
||||||
|
reset_current_chat_run(audit_token)
|
||||||
|
reset_client_timezone(tz_token)
|
||||||
reset_session(token_var)
|
reset_session(token_var)
|
||||||
try:
|
try:
|
||||||
next(session_gen)
|
next(session_gen)
|
||||||
@@ -145,11 +363,26 @@ async def agent_auth_and_session(request: Request, call_next):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI wraps function middlewares in reverse registration order. Register
|
||||||
|
# this after the AG-UI auth middleware so it observes every API response,
|
||||||
|
# including auth failures that return before calling the inner application.
|
||||||
|
app.middleware("http")(log_api_request)
|
||||||
|
|
||||||
|
|
||||||
# Register app routes
|
# Register app routes
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
# Mount the AG-UI agent endpoint.
|
# Mount the AG-UI agent endpoint. The snapshot store persists the chat
|
||||||
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
|
# thread across page reloads; scope is a constant because this is a
|
||||||
|
# single-user app and auth is already enforced by agent_auth_and_session
|
||||||
|
# (the scope resolver only sees the AGUIRequest, which carries no identity).
|
||||||
|
add_agent_framework_fastapi_endpoint(
|
||||||
|
app,
|
||||||
|
build_agent(),
|
||||||
|
AGENT_PATH,
|
||||||
|
snapshot_store=PostgresSnapshotStore(),
|
||||||
|
snapshot_scope_resolver=lambda _request: "default",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from decimal import Decimal
|
|||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
from pydantic import PlainSerializer
|
from pydantic import PlainSerializer
|
||||||
from sqlalchemy import JSON, Column, UniqueConstraint
|
from sqlalchemy import JSON, Column, Text, UniqueConstraint
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
@@ -537,3 +537,67 @@ class WaterMeterReading(WaterMeterReadingBase, table=True):
|
|||||||
class WaterMeterReadingRead(WaterMeterReadingBase):
|
class WaterMeterReadingRead(WaterMeterReadingBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- Assistant Chat Thread ---
|
||||||
|
|
||||||
|
|
||||||
|
class ChatThreadSnapshot(SQLModel, table=True):
|
||||||
|
"""Latest AG-UI thread snapshot per (scope, thread_id).
|
||||||
|
|
||||||
|
Written by app/agent/snapshot_store.py: MAF overwrites the full
|
||||||
|
cumulative message list at each run end — one row per thread, not an
|
||||||
|
append log.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("scope", "thread_id"),)
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
scope: str = Field(index=True)
|
||||||
|
thread_id: str
|
||||||
|
messages: list = Field(
|
||||||
|
default_factory=list,
|
||||||
|
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"),
|
||||||
|
)
|
||||||
|
state: Optional[dict] = Field(
|
||||||
|
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
|
||||||
|
)
|
||||||
|
interrupt: Optional[list] = Field(
|
||||||
|
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
|
||||||
|
)
|
||||||
|
updated_at: datetime = Field(default_factory=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Assistant Chat Run Audit ---
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRunLog(SQLModel, table=True):
|
||||||
|
"""Append-only diagnostic record for an AG-UI agent/run request.
|
||||||
|
|
||||||
|
Thread snapshots are mutable conversation state. This table instead keeps
|
||||||
|
the request, model, tool activity, and final emitted answer for a bounded
|
||||||
|
retention period so individual assistant runs can be investigated later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
request_id: str = Field(index=True, unique=True)
|
||||||
|
agui_run_id: Optional[str] = Field(default=None, index=True)
|
||||||
|
requested_thread_id: Optional[str] = Field(default=None, index=True)
|
||||||
|
response_thread_id: Optional[str] = Field(default=None, index=True)
|
||||||
|
model: str
|
||||||
|
client_ip: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
user_agent: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
browser: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
user_prompt: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
tool_calls: list = Field(
|
||||||
|
default_factory=list,
|
||||||
|
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"),
|
||||||
|
)
|
||||||
|
assistant_response: Optional[str] = Field(
|
||||||
|
default=None, sa_column=Column(Text, nullable=True)
|
||||||
|
)
|
||||||
|
status: str = Field(default="started", index=True)
|
||||||
|
error: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
started_at: datetime = Field(default_factory=utcnow)
|
||||||
|
completed_at: Optional[datetime] = Field(default=None)
|
||||||
|
expires_at: datetime = Field(index=True)
|
||||||
|
|||||||
@@ -1,4 +1,55 @@
|
|||||||
from datetime import datetime, timezone
|
import contextvars
|
||||||
|
from datetime import date, datetime, timedelta, timezone, tzinfo
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
# Costa Rica has no DST; a fixed offset is exact year-round. This is the
|
||||||
|
# FALLBACK when the request doesn't declare a timezone (n8n posts, tests).
|
||||||
|
CR_TZ = timezone(timedelta(hours=-6))
|
||||||
|
|
||||||
|
# Per-request client timezone, set by the agent middleware from the
|
||||||
|
# X-Client-Timezone header (IANA name sent by the browser). Falls back to
|
||||||
|
# Costa Rica so headless callers keep the owner's local semantics.
|
||||||
|
_client_tz: contextvars.ContextVar[tzinfo] = contextvars.ContextVar(
|
||||||
|
"client_tz", default=CR_TZ
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_client_timezone(name: str | None) -> contextvars.Token | None:
|
||||||
|
"""Bind the request's IANA timezone; ignores unknown/absent names.
|
||||||
|
|
||||||
|
Proxies that see the header twice merge the values into a comma list
|
||||||
|
("Zone, Zone") — take the first entry.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _client_tz.set(ZoneInfo(name.split(",")[0].strip()))
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_client_timezone(token: contextvars.Token | None) -> None:
|
||||||
|
if token is not None:
|
||||||
|
_client_tz.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def client_tz() -> tzinfo:
|
||||||
|
return _client_tz.get()
|
||||||
|
|
||||||
|
|
||||||
|
def today_client() -> date:
|
||||||
|
"""Current date where the USER is (request timezone, CR fallback).
|
||||||
|
|
||||||
|
The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow"
|
||||||
|
in UTC. Anything user-facing that means "today" (agent answers, date
|
||||||
|
defaults) must come from here, never date.today().
|
||||||
|
"""
|
||||||
|
return datetime.now(client_tz()).date()
|
||||||
|
|
||||||
|
|
||||||
|
def today_cr() -> date:
|
||||||
|
"""Current date in Costa Rica (UTC-6) — timezone-independent fallback."""
|
||||||
|
return datetime.now(CR_TZ).date()
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
def utcnow() -> datetime:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
a2a-sdk==0.3.23
|
a2a-sdk==0.3.23
|
||||||
ag-ui-protocol==0.1.19
|
ag-ui-protocol==0.1.19
|
||||||
agent-framework==1.2.1
|
agent-framework==1.11.0
|
||||||
agent-framework-a2a==1.0.0b260428
|
agent-framework-a2a==1.0.0b260428
|
||||||
agent-framework-ag-ui==1.0.0b260428
|
agent-framework-ag-ui==1.0.0rc8
|
||||||
agent-framework-anthropic==1.0.0b260428
|
agent-framework-anthropic==1.0.0b260428
|
||||||
agent-framework-azure-ai-search==1.0.0b260428
|
agent-framework-azure-ai-search==1.0.0b260428
|
||||||
agent-framework-azure-cosmos==1.0.0b260428
|
agent-framework-azure-cosmos==1.0.0b260428
|
||||||
@@ -11,7 +11,7 @@ agent-framework-bedrock==1.0.0b260428
|
|||||||
agent-framework-chatkit==1.0.0b260428
|
agent-framework-chatkit==1.0.0b260428
|
||||||
agent-framework-claude==1.0.0b260428
|
agent-framework-claude==1.0.0b260428
|
||||||
agent-framework-copilotstudio==1.0.0b260428
|
agent-framework-copilotstudio==1.0.0b260428
|
||||||
agent-framework-core==1.2.1
|
agent-framework-core==1.11.0
|
||||||
agent-framework-declarative==1.0.0b260428
|
agent-framework-declarative==1.0.0b260428
|
||||||
agent-framework-devui==1.0.0b260428
|
agent-framework-devui==1.0.0b260428
|
||||||
agent-framework-durabletask==1.0.0b260428
|
agent-framework-durabletask==1.0.0b260428
|
||||||
@@ -21,7 +21,7 @@ agent-framework-github-copilot==1.0.0b260402
|
|||||||
agent-framework-lab==1.0.0b251024
|
agent-framework-lab==1.0.0b251024
|
||||||
agent-framework-mem0==1.0.0b260428
|
agent-framework-mem0==1.0.0b260428
|
||||||
agent-framework-ollama==1.0.0b260428
|
agent-framework-ollama==1.0.0b260428
|
||||||
agent-framework-openai==1.2.1
|
agent-framework-openai==1.10.1
|
||||||
agent-framework-orchestrations==1.0.0b260428
|
agent-framework-orchestrations==1.0.0b260428
|
||||||
agent-framework-purview==1.0.0b260428
|
agent-framework-purview==1.0.0b260428
|
||||||
agent-framework-redis==1.0.0b260428
|
agent-framework-redis==1.0.0b260428
|
||||||
@@ -165,7 +165,7 @@ six==1.17.0
|
|||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
SQLAlchemy==2.0.50
|
SQLAlchemy==2.0.50
|
||||||
sqlmodel==0.0.38
|
sqlmodel==0.0.38
|
||||||
sse-starlette==3.4.4
|
sse-starlette==3.4.5
|
||||||
starlette==1.2.1
|
starlette==1.2.1
|
||||||
tenacity==9.1.4
|
tenacity==9.1.4
|
||||||
tqdm==4.68.2
|
tqdm==4.68.2
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ httpx
|
|||||||
pywebpush
|
pywebpush
|
||||||
py-vapid
|
py-vapid
|
||||||
python-dateutil
|
python-dateutil
|
||||||
agent-framework==1.2.1
|
agent-framework==1.11.0
|
||||||
agent-framework-ag-ui==1.0.0b260428
|
agent-framework-ag-ui==1.0.0rc8
|
||||||
agent-framework-openai==1.2.1
|
agent-framework-openai==1.10.1
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ os.environ.setdefault(
|
|||||||
os.environ.setdefault("ADMIN_USERNAME", "testadmin")
|
os.environ.setdefault("ADMIN_USERNAME", "testadmin")
|
||||||
os.environ.setdefault("ADMIN_PASSWORD", "test-password")
|
os.environ.setdefault("ADMIN_PASSWORD", "test-password")
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
||||||
|
# app.main builds the MAF agent at import; the OpenAI client only needs a
|
||||||
|
# syntactically-present key.
|
||||||
|
os.environ.setdefault("OPENAI_API_KEY", "test-dummy-key")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|||||||
14
backend/tests/test_agent_config.py
Normal file
14
backend/tests/test_agent_config.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Assistant transport configuration."""
|
||||||
|
|
||||||
|
from agent_framework.openai import OpenAIChatClient
|
||||||
|
|
||||||
|
from app.agent import agent as agent_module
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_uses_responses_client_for_the_configured_model(monkeypatch):
|
||||||
|
monkeypatch.setattr(agent_module.settings, "AGENT_MODEL", "gpt-5.6-luna")
|
||||||
|
|
||||||
|
agent = agent_module.build_agent()
|
||||||
|
|
||||||
|
assert isinstance(agent.client, OpenAIChatClient)
|
||||||
|
assert agent.client.model == "gpt-5.6-luna"
|
||||||
@@ -17,6 +17,7 @@ from app.models.models import (
|
|||||||
MunicipalReceipt,
|
MunicipalReceipt,
|
||||||
PensionSnapshot,
|
PensionSnapshot,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
TransactionType,
|
||||||
WaterMeterReading,
|
WaterMeterReading,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -149,3 +150,137 @@ def test_recent_transactions_json_safe(bound_session):
|
|||||||
out = tools.get_recent_transactions()
|
out = tools.get_recent_transactions()
|
||||||
json.dumps(out)
|
json.dumps(out)
|
||||||
assert out[0]["amount"] == 42.42
|
assert out[0]["amount"] == 42.42
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_transactions_exclude_future_cuotas(bound_session):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app.timeutil import today_cr
|
||||||
|
|
||||||
|
today = datetime.combine(today_cr(), datetime.min.time())
|
||||||
|
bound_session.add(
|
||||||
|
Transaction(amount=Decimal("10"), merchant="BILLED", date=today)
|
||||||
|
)
|
||||||
|
bound_session.add(
|
||||||
|
Transaction(
|
||||||
|
amount=Decimal("20"),
|
||||||
|
merchant="FUTURE CUOTA:03/06",
|
||||||
|
date=today + timedelta(days=45),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bound_session.commit()
|
||||||
|
merchants = [t["merchant"] for t in tools.get_recent_transactions()]
|
||||||
|
assert "BILLED" in merchants
|
||||||
|
assert "FUTURE CUOTA:03/06" not in merchants
|
||||||
|
|
||||||
|
|
||||||
|
def test_salary_deposits_returns_individual_filtered_rows(bound_session):
|
||||||
|
bound_session.add_all(
|
||||||
|
[
|
||||||
|
Transaction(
|
||||||
|
amount=Decimal("1500"),
|
||||||
|
merchant="SALARY JULY",
|
||||||
|
date=datetime(2026, 7, 14, 9),
|
||||||
|
transaction_type=TransactionType.SALARY,
|
||||||
|
reference="PAY-2026-07",
|
||||||
|
),
|
||||||
|
Transaction(
|
||||||
|
amount=Decimal("1400"),
|
||||||
|
merchant="SALARY JUNE",
|
||||||
|
date=datetime(2026, 6, 14, 9),
|
||||||
|
transaction_type=TransactionType.SALARY,
|
||||||
|
),
|
||||||
|
Transaction(
|
||||||
|
amount=Decimal("999"),
|
||||||
|
merchant="PURCHASE",
|
||||||
|
date=datetime(2026, 7, 14, 10),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
bound_session.commit()
|
||||||
|
|
||||||
|
out = tools.get_salary_deposits(
|
||||||
|
start_date="2026-07-14", end_date="2026-07-15"
|
||||||
|
)
|
||||||
|
|
||||||
|
json.dumps(out)
|
||||||
|
assert out == [
|
||||||
|
{
|
||||||
|
"id": out[0]["id"],
|
||||||
|
"date": "2026-07-14T09:00:00",
|
||||||
|
"amount": 1500.0,
|
||||||
|
"currency": "CRC",
|
||||||
|
"merchant": "SALARY JULY",
|
||||||
|
"source": "CREDIT_CARD",
|
||||||
|
"bank": "BAC",
|
||||||
|
"transaction_type": "SALARY",
|
||||||
|
"reference": "PAY-2026-07",
|
||||||
|
"notes": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_current_date_is_costa_rica_and_cycle_consistent():
|
||||||
|
from app.timeutil import today_cr
|
||||||
|
|
||||||
|
out = tools.get_current_date()
|
||||||
|
today = today_cr()
|
||||||
|
assert out["date"] == today.isoformat()
|
||||||
|
# The active cycle must contain today: [start, end)
|
||||||
|
start, end = out["cycle_range"]
|
||||||
|
assert start <= out["date"] < end
|
||||||
|
assert start.endswith("-18") and end.endswith("-18")
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_spending_day_scoped(bound_session):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app.timeutil import today_cr
|
||||||
|
|
||||||
|
today = datetime.combine(today_cr(), datetime.min.time())
|
||||||
|
bound_session.add(
|
||||||
|
Transaction(amount=Decimal("100"), merchant="TODAY-A", date=today.replace(hour=9))
|
||||||
|
)
|
||||||
|
bound_session.add(
|
||||||
|
Transaction(amount=Decimal("50"), merchant="TODAY-B", date=today.replace(hour=15))
|
||||||
|
)
|
||||||
|
bound_session.add(
|
||||||
|
Transaction(
|
||||||
|
amount=Decimal("999"),
|
||||||
|
merchant="FUTURE CUOTA",
|
||||||
|
date=today + timedelta(days=40),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bound_session.commit()
|
||||||
|
|
||||||
|
out = tools.get_daily_spending(
|
||||||
|
start_date=today.date().isoformat(),
|
||||||
|
end_date=(today.date() + timedelta(days=60)).isoformat(),
|
||||||
|
)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["date"] == today.date().isoformat()
|
||||||
|
assert out[0]["total_crc"] == 150.0
|
||||||
|
assert out[0]["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_current_date_respects_client_timezone():
|
||||||
|
from datetime import datetime, timezone as tz
|
||||||
|
|
||||||
|
from app.timeutil import (
|
||||||
|
client_tz,
|
||||||
|
reset_client_timezone,
|
||||||
|
set_client_timezone,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = set_client_timezone("Asia/Tokyo")
|
||||||
|
try:
|
||||||
|
out = tools.get_current_date()
|
||||||
|
assert out["timezone"] == "Asia/Tokyo"
|
||||||
|
assert out["date"] == datetime.now(client_tz()).date().isoformat()
|
||||||
|
finally:
|
||||||
|
reset_client_timezone(token)
|
||||||
|
|
||||||
|
# Unknown names are ignored — CR fallback stays active.
|
||||||
|
assert set_client_timezone("Not/AZone") is None
|
||||||
|
assert set_client_timezone(None) is None
|
||||||
|
assert tools.get_current_date()["timezone"] == "UTC-06:00"
|
||||||
|
|||||||
139
backend/tests/test_chat_audit.py
Normal file
139
backend/tests/test_chat_audit.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Chat-run audit persistence and redacted HTTP access logs."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from agent_framework_ag_ui import AGUIThreadSnapshot
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
import app.agent.chat_audit as chat_audit
|
||||||
|
import app.agent.snapshot_store as snapshot_store_module
|
||||||
|
from app.agent.snapshot_store import PostgresSnapshotStore
|
||||||
|
from app.models.models import ChatRunLog
|
||||||
|
from app.timeutil import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_store(monkeypatch, engine):
|
||||||
|
monkeypatch.setattr(chat_audit, "engine", engine)
|
||||||
|
monkeypatch.setattr(snapshot_store_module, "engine", engine)
|
||||||
|
return PostgresSnapshotStore()
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_completes_chat_run_with_tools_and_response(monkeypatch, engine, session):
|
||||||
|
store = _audit_store(monkeypatch, engine)
|
||||||
|
run_id = chat_audit.create_chat_run(
|
||||||
|
request_id="req-1",
|
||||||
|
agui_run_id="run-1",
|
||||||
|
thread_id="main",
|
||||||
|
messages=[{"role": "user", "content": "¿Cuál es mi saldo?"}],
|
||||||
|
client_ip="203.0.113.7",
|
||||||
|
user_agent="Mozilla/5.0 TestBrowser/1.0",
|
||||||
|
browser="TestBrowser",
|
||||||
|
)
|
||||||
|
token = chat_audit.set_current_chat_run(run_id)
|
||||||
|
try:
|
||||||
|
asyncio.run(
|
||||||
|
store.save(
|
||||||
|
scope="default",
|
||||||
|
thread_id="resp_123",
|
||||||
|
snapshot=AGUIThreadSnapshot(
|
||||||
|
messages=[
|
||||||
|
{"id": "u1", "role": "user", "content": "¿Cuál es mi saldo?"},
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": None,
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call-1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "get_net_worth", "arguments": "{}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"id": "t1", "role": "tool", "tool_call_id": "call-1", "content": "{\"net_crc\": 1}"},
|
||||||
|
{"id": "a2", "role": "assistant", "content": "Tu saldo es ₡1."},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
chat_audit.reset_current_chat_run(token)
|
||||||
|
|
||||||
|
row = session.get(ChatRunLog, run_id)
|
||||||
|
assert row is not None
|
||||||
|
assert row.status == "completed"
|
||||||
|
assert row.agui_run_id == "run-1"
|
||||||
|
assert row.response_thread_id == "resp_123"
|
||||||
|
assert row.user_prompt == "¿Cuál es mi saldo?"
|
||||||
|
assert row.client_ip == "203.0.113.7"
|
||||||
|
assert row.user_agent == "Mozilla/5.0 TestBrowser/1.0"
|
||||||
|
assert row.browser == "TestBrowser"
|
||||||
|
assert row.assistant_response == "Tu saldo es ₡1."
|
||||||
|
assert row.tool_calls == [
|
||||||
|
{
|
||||||
|
"id": "call-1",
|
||||||
|
"name": "get_net_worth",
|
||||||
|
"arguments": "{}",
|
||||||
|
"result": '{"net_crc": 1}',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_and_retention_cleanup(monkeypatch, engine, session):
|
||||||
|
_audit_store(monkeypatch, engine)
|
||||||
|
failed_id = chat_audit.create_chat_run(
|
||||||
|
request_id="req-failed", agui_run_id="run-failed", thread_id="main", messages=[]
|
||||||
|
)
|
||||||
|
chat_audit.mark_chat_run_failed(failed_id, "RuntimeError: provider unavailable")
|
||||||
|
assert session.get(ChatRunLog, failed_id).status == "failed"
|
||||||
|
|
||||||
|
expired = ChatRunLog(
|
||||||
|
request_id="req-expired",
|
||||||
|
model="gpt-5.6-luna",
|
||||||
|
expires_at=utcnow() - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
session.add(expired)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert chat_audit.purge_expired_chat_runs() == 1
|
||||||
|
assert session.exec(select(ChatRunLog).where(ChatRunLog.request_id == "req-expired")).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_access_log_never_contains_request_body(monkeypatch):
|
||||||
|
from app.main import log_api_request
|
||||||
|
|
||||||
|
logged = []
|
||||||
|
monkeypatch.setattr("app.main.log_event", lambda event, **fields: logged.append((event, fields)))
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/auth/login",
|
||||||
|
"raw_path": b"/api/auth/login",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [
|
||||||
|
(b"x-real-ip", b"203.0.113.9"),
|
||||||
|
(b"user-agent", b"Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"),
|
||||||
|
],
|
||||||
|
"scheme": "http",
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
"client": ("testclient", 1234),
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
|
||||||
|
async def call_next(_request):
|
||||||
|
return Response(status_code=201)
|
||||||
|
|
||||||
|
response = asyncio.run(log_api_request(request, call_next))
|
||||||
|
assert response.headers["X-Request-ID"]
|
||||||
|
assert logged[0][0] == "api_request"
|
||||||
|
fields = logged[0][1]
|
||||||
|
assert fields["path"] == "/api/auth/login"
|
||||||
|
assert fields["status_code"] == 201
|
||||||
|
assert fields["client_ip"] == "203.0.113.9"
|
||||||
|
assert fields["browser"] == "Chrome"
|
||||||
|
assert fields["user_agent"] == "Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"
|
||||||
|
assert "password" not in fields
|
||||||
|
assert "body" not in fields
|
||||||
167
backend/tests/test_chat_persistence.py
Normal file
167
backend/tests/test_chat_persistence.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Chat thread persistence: snapshot store round-trip + clear endpoint."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from agent_framework_ag_ui import AGUIThreadSnapshot
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
import app.agent.snapshot_store as snapshot_store_module
|
||||||
|
from app.agent.snapshot_store import PostgresSnapshotStore
|
||||||
|
from app.api.v1.endpoints.chat import clear_thread
|
||||||
|
from app.models.models import ChatThreadSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _store(monkeypatch, engine):
|
||||||
|
# The store opens its own sessions from app.db.engine; point it at the
|
||||||
|
# test engine instead.
|
||||||
|
monkeypatch.setattr(snapshot_store_module, "engine", engine)
|
||||||
|
return PostgresSnapshotStore()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_get_roundtrip_normalizes_and_filters(monkeypatch, engine):
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
snapshot = AGUIThreadSnapshot(
|
||||||
|
messages=[
|
||||||
|
{"id": "u1", "role": "user", "content": "hola"},
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
# MAF emits snake_case; the store must return camelCase so
|
||||||
|
# @ag-ui/client's Zod schema doesn't strip it.
|
||||||
|
"tool_calls": [{"id": "t1", "type": "function"}],
|
||||||
|
},
|
||||||
|
{"id": "t1r", "role": "tool", "content": "ok", "tool_call_id": "t1"},
|
||||||
|
# MAF writes tool_calls: null on plain-text assistant messages —
|
||||||
|
# must not survive as toolCalls: null in the replay.
|
||||||
|
{"id": "a2", "role": "assistant", "content": "done", "tool_calls": None},
|
||||||
|
{"id": "ctx-123", "role": "system", "content": "run context"},
|
||||||
|
],
|
||||||
|
state={"k": "v"},
|
||||||
|
)
|
||||||
|
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snapshot))
|
||||||
|
|
||||||
|
loaded = asyncio.run(store.get(scope="default", thread_id="main"))
|
||||||
|
assert loaded is not None
|
||||||
|
assert [m["id"] for m in loaded.messages] == ["u1", "a1", "t1r", "a2"]
|
||||||
|
assistant = loaded.messages[1]
|
||||||
|
assert "tool_calls" not in assistant
|
||||||
|
assert assistant["toolCalls"] == [{"id": "t1", "type": "function"}]
|
||||||
|
tool_msg = loaded.messages[2]
|
||||||
|
assert "tool_call_id" not in tool_msg
|
||||||
|
assert tool_msg["toolCallId"] == "t1"
|
||||||
|
text_msg = loaded.messages[3]
|
||||||
|
assert "toolCalls" not in text_msg and "tool_calls" not in text_msg
|
||||||
|
assert loaded.state == {"k": "v"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_upserts_single_row(monkeypatch, engine, session):
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
for n in range(3):
|
||||||
|
snap = AGUIThreadSnapshot(
|
||||||
|
messages=[{"id": f"u{i}", "role": "user", "content": str(i)} for i in range(n + 1)]
|
||||||
|
)
|
||||||
|
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
|
||||||
|
|
||||||
|
rows = session.exec(select(ChatThreadSnapshot)).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert len(rows[0].messages) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_unknown_thread_returns_none(monkeypatch, engine):
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
assert asyncio.run(store.get(scope="default", thread_id="nope")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_and_scope_isolation(monkeypatch, engine):
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
snap = AGUIThreadSnapshot(messages=[{"id": "u1", "role": "user", "content": "x"}])
|
||||||
|
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
|
||||||
|
|
||||||
|
assert asyncio.run(store.get(scope="other", thread_id="main")) is None
|
||||||
|
assert asyncio.run(store.delete(scope="default", thread_id="main")) is True
|
||||||
|
assert asyncio.run(store.delete(scope="default", thread_id="main")) is False
|
||||||
|
assert asyncio.run(store.get(scope="default", thread_id="main")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_dedupes_multi_run_turn(monkeypatch, engine):
|
||||||
|
"""Mirror of MAF's duplicate merge on frontend-tool roundtrips: repeated
|
||||||
|
message ids, a re-issued identical render call, and extra tool results."""
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
|
||||||
|
def call(cid, name, args='{"a":1}'):
|
||||||
|
return {"id": cid, "type": "function", "function": {"name": name, "arguments": args}}
|
||||||
|
|
||||||
|
snapshot = AGUIThreadSnapshot(
|
||||||
|
messages=[
|
||||||
|
{"id": "u1", "role": "user", "content": "resumen"},
|
||||||
|
{"id": "a1", "role": "assistant", "content": "",
|
||||||
|
"tool_calls": [call("c1", "get_cycle_summary"), call("c2", "render_spending_summary")]},
|
||||||
|
{"id": "t1", "role": "tool", "content": "data", "tool_call_id": "c1"},
|
||||||
|
# MAF re-records the resent suffix verbatim…
|
||||||
|
{"id": "u1", "role": "user", "content": "resumen"},
|
||||||
|
{"id": "t1", "role": "tool", "content": "data", "tool_call_id": "c1"},
|
||||||
|
{"id": "t2", "role": "tool", "content": "ok", "tool_call_id": "c2"},
|
||||||
|
# …plus a synthetic second result for an answered call…
|
||||||
|
{"id": "t3", "role": "tool", "content": "", "tool_call_id": "c1"},
|
||||||
|
# …and the model re-issues the same render call with a new id.
|
||||||
|
{"id": "a2", "role": "assistant", "content": "",
|
||||||
|
"tool_calls": [call("c3", "render_spending_summary")]},
|
||||||
|
{"id": "t4", "role": "tool", "content": "ok", "tool_call_id": "c3"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snapshot))
|
||||||
|
|
||||||
|
loaded = asyncio.run(store.get(scope="default", thread_id="main"))
|
||||||
|
assert [m["id"] for m in loaded.messages] == ["u1", "a1", "t1", "t2"]
|
||||||
|
# The duplicate render call (c3) and its result are gone; each surviving
|
||||||
|
# call has exactly one result.
|
||||||
|
assert [tc["id"] for tc in loaded.messages[1]["toolCalls"]] == ["c1", "c2"]
|
||||||
|
assert [(m["toolCallId"]) for m in loaded.messages if m["role"] == "tool"] == ["c1", "c2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_thread_endpoint(monkeypatch, engine, session):
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
assert clear_thread(session=session, _user="testadmin") == {"cleared": False}
|
||||||
|
|
||||||
|
snap = AGUIThreadSnapshot(messages=[{"id": "u1", "role": "user", "content": "x"}])
|
||||||
|
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
|
||||||
|
|
||||||
|
assert clear_thread(session=session, _user="testadmin") == {"cleared": True}
|
||||||
|
assert asyncio.run(store.get(scope="default", thread_id="main")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_stale_client_history(session, monkeypatch, engine):
|
||||||
|
from app.main import _drop_stale_client_history
|
||||||
|
|
||||||
|
history = [
|
||||||
|
{"role": "user", "content": "vieja pregunta"},
|
||||||
|
{"role": "assistant", "content": "vieja respuesta"},
|
||||||
|
{"role": "tool", "content": "x", "toolCallId": "t1"},
|
||||||
|
{"role": "user", "content": "nueva pregunta"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# No stored snapshot for the thread → stale client: keep last user turn.
|
||||||
|
assert _drop_stale_client_history(session, "main", list(history)) == [
|
||||||
|
{"role": "user", "content": "nueva pregunta"}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fresh conversation (no assistant/tool yet) passes through.
|
||||||
|
fresh = [{"role": "user", "content": "hola"}]
|
||||||
|
assert _drop_stale_client_history(session, "main", list(fresh)) == fresh
|
||||||
|
|
||||||
|
# Empty messages (hydration trigger) pass through.
|
||||||
|
assert _drop_stale_client_history(session, "main", []) == []
|
||||||
|
|
||||||
|
# With a stored snapshot the full history is legitimate.
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
asyncio.run(
|
||||||
|
store.save(
|
||||||
|
scope="default",
|
||||||
|
thread_id="main",
|
||||||
|
snapshot=AGUIThreadSnapshot(
|
||||||
|
messages=[{"id": "u1", "role": "user", "content": "hola"}]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert _drop_stale_client_history(session, "main", list(history)) == history
|
||||||
@@ -16,6 +16,11 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -31,11 +36,17 @@ services:
|
|||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
|
||||||
|
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
|
||||||
|
ENABLE_INSTRUMENTATION: "false"
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
networks:
|
networks:
|
||||||
- wealthysmart-network
|
wealthysmart-network:
|
||||||
|
# Avoid the generic `backend` name, which is also used on the shared
|
||||||
|
# nginx network by another application.
|
||||||
|
aliases:
|
||||||
|
- wealthysmart-api
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -45,6 +56,11 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -57,8 +73,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
BACKEND_URL: http://wealthysmart-backend-prod:8000
|
BACKEND_URL: http://wealthysmart-api:8000
|
||||||
AGENT_URL: http://wealthysmart-backend-prod:8000/api/v1/agent/agui
|
AGENT_URL: http://wealthysmart-api:8000/api/v1/agent/agui
|
||||||
JWT_SECRET: ${SECRET_KEY}
|
JWT_SECRET: ${SECRET_KEY}
|
||||||
COOKIE_DOMAIN: wealth.cescalante.dev
|
COOKIE_DOMAIN: wealth.cescalante.dev
|
||||||
COOKIE_SECURE: "true"
|
COOKIE_SECURE: "true"
|
||||||
@@ -78,6 +94,11 @@ services:
|
|||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
wealthysmart-network:
|
wealthysmart-network:
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -30,7 +35,9 @@ services:
|
|||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
|
||||||
|
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
|
||||||
|
ENABLE_INSTRUMENTATION: "false"
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -38,6 +45,11 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- path: ./backend/app
|
- path: ./backend/app
|
||||||
@@ -58,9 +70,17 @@ services:
|
|||||||
- "5175:3000"
|
- "5175:3000"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
# Vite runs inside this container, so localhost would refer to the
|
||||||
|
# frontend rather than the Python service.
|
||||||
|
BACKEND_URL: http://backend:8000
|
||||||
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- path: ./frontend/src
|
- path: ./frontend/src
|
||||||
|
|||||||
113
frontend/e2e/asistente.spec.ts
Normal file
113
frontend/e2e/asistente.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
test.setTimeout(150_000);
|
||||||
|
|
||||||
|
async function startFreshAssistant(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
|
||||||
|
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
|
||||||
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL("/");
|
||||||
|
|
||||||
|
const initialConnect = page.waitForResponse((response) => {
|
||||||
|
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.goto("/asistente");
|
||||||
|
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
|
||||||
|
await (await initialConnect).finished();
|
||||||
|
|
||||||
|
const newConversation = page.getByRole("button", { name: "Nueva conversación" });
|
||||||
|
if (await newConversation.isEnabled()) {
|
||||||
|
await newConversation.click();
|
||||||
|
const reconnect = page.waitForResponse((response) => {
|
||||||
|
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Borrar historial" }).click();
|
||||||
|
await (await reconnect).finished();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: "Escribe tu pregunta…" }),
|
||||||
|
).toBeEnabled({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(page: Page, question: string, expectedTool: string) {
|
||||||
|
// The BFF response is the exact CopilotKit stream consumed by the browser.
|
||||||
|
// Checking it catches server-side RUN_ERROR events even if the UI changes.
|
||||||
|
const stream = page.waitForResponse(
|
||||||
|
(response) => {
|
||||||
|
if (
|
||||||
|
!response.url().includes("/api/copilotkit") ||
|
||||||
|
response.request().method() !== "POST"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.request().postData() ?? "{}").method === "agent/run";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const composer = page.getByRole("textbox", { name: "Escribe tu pregunta…" });
|
||||||
|
await expect(composer).toBeEnabled({ timeout: 30_000 });
|
||||||
|
await composer.fill(question);
|
||||||
|
const sendButton = page.getByTestId("copilot-send-button");
|
||||||
|
await expect(sendButton).toBeEnabled({ timeout: 30_000 });
|
||||||
|
await sendButton.click();
|
||||||
|
await expect(page.getByText(question, { exact: true }).last()).toBeVisible();
|
||||||
|
|
||||||
|
const response = await stream;
|
||||||
|
await response.finished();
|
||||||
|
const events = await response.text();
|
||||||
|
expect(events).not.toContain("RUN_ERROR");
|
||||||
|
expect(events).toContain(expectedTool);
|
||||||
|
|
||||||
|
// The HTTP stream may finish before CopilotKit has committed the tool run
|
||||||
|
// and released its client-side running state. Waiting for the page action
|
||||||
|
// avoids losing the next Enter keypress during that short interval.
|
||||||
|
await expect(page.getByRole("button", { name: "Nueva conversación" })).toBeEnabled({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Asistente completes Luna's read-only tool flows", async ({ page }) => {
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") consoleErrors.push(message.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
await startFreshAssistant(page);
|
||||||
|
|
||||||
|
await ask(page, "What date is it today?", "get_current_date");
|
||||||
|
await ask(page, "List my last 10 transactions.", "get_recent_transactions");
|
||||||
|
await ask(page, "What is my net worth?", "get_net_worth");
|
||||||
|
await ask(page, "How much did I spend today?", "get_daily_spending");
|
||||||
|
await ask(page, "Show my spending by category this month.", "render_spending_summary");
|
||||||
|
|
||||||
|
const spendingCard = page.getByTestId("spending-summary-card");
|
||||||
|
await expect(spendingCard).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("textbox", { name: "Escribe tu pregunta…" })).toBeEnabled({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
|
||||||
|
await expect(page.getByText("Show my spending by category this month.", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("spending-summary-card")).toBeVisible();
|
||||||
|
|
||||||
|
expect(consoleErrors.join("\n")).not.toContain("ChatClientException");
|
||||||
|
});
|
||||||
127
frontend/e2e/feature-doc.ts
Normal file
127
frontend/e2e/feature-doc.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test, type Page, type TestInfo } from "@playwright/test";
|
||||||
|
import * as narration from "./narration";
|
||||||
|
import { renderFinalVideo } from "./video-render";
|
||||||
|
import type { Clip } from "./subtitles";
|
||||||
|
|
||||||
|
const DEMO = !!process.env.PW_DEMO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps test.step() so every step of a demo spec produces a captioned
|
||||||
|
* screenshot, and — in PW_DEMO mode — a native Playwright screencast
|
||||||
|
* recording with cursor annotations, chapter-title cards, burned-in
|
||||||
|
* subtitles and (when `say` is available) spoken Spanish narration.
|
||||||
|
* Screenshots/video/subtitles land in e2e-docs/<slug>/ and are also
|
||||||
|
* attached to the Playwright HTML report; save() stitches everything into
|
||||||
|
* a Markdown walkthrough that can be published as-is.
|
||||||
|
*/
|
||||||
|
export class FeatureDoc {
|
||||||
|
private steps: { title: string; caption: string; file: string }[] = [];
|
||||||
|
private clips: Clip[] = [];
|
||||||
|
private readonly dir: string;
|
||||||
|
private recordingStartedAt = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private page: Page,
|
||||||
|
private testInfo: TestInfo,
|
||||||
|
private slug: string,
|
||||||
|
private title: string,
|
||||||
|
private intro: string,
|
||||||
|
) {
|
||||||
|
this.dir = path.resolve("e2e-docs", slug);
|
||||||
|
fs.rmSync(this.dir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(this.dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the native screencast recording. Call before the first step(). */
|
||||||
|
async start() {
|
||||||
|
if (!DEMO) return;
|
||||||
|
this.recordingStartedAt = Date.now();
|
||||||
|
await this.page.screencast.start({
|
||||||
|
path: path.join(this.dir, "raw.webm"),
|
||||||
|
size: { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
|
await this.page.screencast.showActions({ cursor: "pointer" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async step(title: string, caption: string, fn: () => Promise<void>) {
|
||||||
|
const narrationPromise = DEMO
|
||||||
|
? narration.synthesize(caption, this.testInfo.outputDir, this.clips.length)
|
||||||
|
: Promise.resolve(null);
|
||||||
|
|
||||||
|
const stepStart = Date.now();
|
||||||
|
if (DEMO) {
|
||||||
|
await this.page.screencast.showChapter(title, { description: caption, duration: 1500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await test.step(title, fn);
|
||||||
|
|
||||||
|
const narrationResult = await narrationPromise;
|
||||||
|
if (DEMO) {
|
||||||
|
const elapsed = Date.now() - stepStart;
|
||||||
|
const target = (narrationResult?.durationMs ?? 0) + 300;
|
||||||
|
if (target > elapsed) await this.page.waitForTimeout(target - elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = `${String(this.steps.length + 1).padStart(2, "0")}-${title
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]+/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")}.png`;
|
||||||
|
const body = await this.page.screenshot({
|
||||||
|
path: path.join(this.dir, file),
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
await this.testInfo.attach(title, { body, contentType: "image/png" });
|
||||||
|
this.steps.push({ title, caption, file });
|
||||||
|
|
||||||
|
if (DEMO) {
|
||||||
|
this.clips.push({
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
file,
|
||||||
|
startMs: stepStart - this.recordingStartedAt,
|
||||||
|
endMs: Date.now() - this.recordingStartedAt,
|
||||||
|
narration: narrationResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
let videoName = "video.mp4";
|
||||||
|
if (DEMO) {
|
||||||
|
await this.page.screencast.stop();
|
||||||
|
const { videoFile, degraded } = await renderFinalVideo({
|
||||||
|
rawWebmPath: path.join(this.dir, "raw.webm"),
|
||||||
|
clips: this.clips,
|
||||||
|
outDir: this.dir,
|
||||||
|
});
|
||||||
|
for (const warning of degraded) console.warn(`[${this.slug}] ${warning}`);
|
||||||
|
videoName = path.basename(videoFile);
|
||||||
|
await this.testInfo.attach("video", {
|
||||||
|
path: videoFile,
|
||||||
|
contentType: videoName.endsWith(".mp4") ? "video/mp4" : "video/webm",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const md = [
|
||||||
|
`# ${this.title}`,
|
||||||
|
"",
|
||||||
|
this.intro,
|
||||||
|
"",
|
||||||
|
`> Generado automáticamente por \`${path.basename(this.testInfo.file)}\` — ${this.steps.length} pasos. El video completo del recorrido está en [${videoName}](${videoName}).`,
|
||||||
|
"",
|
||||||
|
...this.steps.flatMap((s, i) => [
|
||||||
|
`## ${i + 1}. ${s.title}`,
|
||||||
|
"",
|
||||||
|
s.caption,
|
||||||
|
"",
|
||||||
|
``,
|
||||||
|
"",
|
||||||
|
]),
|
||||||
|
].join("\n");
|
||||||
|
fs.writeFileSync(path.join(this.dir, "index.md"), md);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
frontend/e2e/financiamientos-demo.spec.ts
Normal file
102
frontend/e2e/financiamientos-demo.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { FeatureDoc } from "./feature-doc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo walkthrough of the Financiamientos (Tasa Cero) feature. Doubles as an
|
||||||
|
* e2e smoke test and as the generator for e2e-docs/financiamientos/ — a
|
||||||
|
* captioned, screenshot-per-step Markdown doc plus the full video (native
|
||||||
|
* cursor/chapters, burned-in subtitles, spoken narration) in the Playwright
|
||||||
|
* HTML report.
|
||||||
|
*/
|
||||||
|
// NOTE: do not `test.use({ video: "off" })` here — disabling the fixture
|
||||||
|
// video breaks page.screencast's internal sizing (verified: it then records
|
||||||
|
// a shrunken frame letterboxed inside the requested canvas). The fixture's
|
||||||
|
// own video ends up redundant/unused but harmless.
|
||||||
|
|
||||||
|
test("@demo Financiamientos — compras Tasa Cero en cuotas", async ({ page }, testInfo) => {
|
||||||
|
const doc = new FeatureDoc(
|
||||||
|
page,
|
||||||
|
testInfo,
|
||||||
|
"financiamientos",
|
||||||
|
"Financiamientos — Tasa Cero",
|
||||||
|
"WealthySmart trackea las compras BAC Tasa Cero como planes de cuotas mensuales: cada cuota futura ya cuenta en el presupuesto del mes que le toca, en lugar de registrar la compra como un solo pago.",
|
||||||
|
);
|
||||||
|
await doc.start();
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Iniciar sesión",
|
||||||
|
"El acceso está protegido con usuario y contraseña; la sesión vive en una cookie HTTP-only.",
|
||||||
|
async () => {
|
||||||
|
await page.goto("/login");
|
||||||
|
// CopilotKit mounts its dev web-inspector in dev builds; keep it out of
|
||||||
|
// the recording — it isn't part of the product.
|
||||||
|
await page.addStyleTag({ content: "cpk-web-inspector { display: none !important; }" });
|
||||||
|
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
|
||||||
|
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
|
||||||
|
await expect(page.getByRole("button", { name: /sign in/i })).toBeEnabled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Panel de inicio",
|
||||||
|
"Después del login se llega al dashboard Inicio, con el resumen del mes y la navegación lateral por módulos.",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL("/");
|
||||||
|
await expect(page.getByRole("link", { name: "Financiamientos", exact: true })).toBeVisible();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Modo privacidad",
|
||||||
|
"El botón de privacidad difumina todos los montos sensibles — útil para compartir pantalla (y para este demo).",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("button", { name: "Toggle privacy mode" }).click();
|
||||||
|
await expect(page.locator("html")).toHaveClass(/privacy/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Página de Financiamientos",
|
||||||
|
"El módulo resume todos los planes Tasa Cero: saldo faltante total, cuánto suman las cuotas de los planes activos cada mes, y cuántos planes siguen vivos.",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("link", { name: "Financiamientos", exact: true }).click();
|
||||||
|
await page.waitForURL("/financiamientos");
|
||||||
|
await expect(page.getByText("Saldo faltante total")).toBeVisible();
|
||||||
|
await expect(page.getByText("Cuotas por mes (activos)")).toBeVisible();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Tabla de planes",
|
||||||
|
"Cada plan muestra el comercio, el avance de cuotas (facturadas vs. totales), el monto de la cuota, el saldo que falta y cuándo cae la próxima cuota.",
|
||||||
|
async () => {
|
||||||
|
const rows = page.getByRole("button", { name: /editar plan de/i });
|
||||||
|
await expect(rows.first()).toBeVisible();
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0);
|
||||||
|
await expect(page.getByRole("columnheader", { name: "Próxima cuota" })).toBeVisible();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Detalle de un plan",
|
||||||
|
"Al hacer clic en un plan se abre su detalle editable: fecha ancla, número de cuotas y monto. Desde aquí también se puede deshacer la conversión a Tasa Cero.",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("button", { name: /editar plan de/i }).first().click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeVisible();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Cerrar el detalle",
|
||||||
|
"El plan queda igual que estaba — el recorrido es de solo lectura.",
|
||||||
|
async () => {
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeHidden();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
});
|
||||||
59
frontend/e2e/narration.ts
Normal file
59
frontend/e2e/narration.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
let sayChecked = false;
|
||||||
|
let sayAvailable = false;
|
||||||
|
|
||||||
|
function hasSay() {
|
||||||
|
if (!sayChecked) {
|
||||||
|
sayChecked = true;
|
||||||
|
try {
|
||||||
|
execFileSync("say", ["-v", "?"], { stdio: "ignore" });
|
||||||
|
sayAvailable = true;
|
||||||
|
} catch {
|
||||||
|
console.warn("`say` not found — skipping TTS narration (subtitles still work).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sayAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, Promise<{ path: string; durationMs: number } | null>>();
|
||||||
|
|
||||||
|
/** Synthesizes Spanish narration for a caption via macOS `say`. Returns null (never throws) if unavailable. */
|
||||||
|
export function synthesize(
|
||||||
|
text: string,
|
||||||
|
outDir: string,
|
||||||
|
index: number,
|
||||||
|
voice = "Paulina",
|
||||||
|
): Promise<{ path: string; durationMs: number } | null> {
|
||||||
|
const key = `${voice}:${text}`;
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
if (!hasSay()) return null;
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const file = path.join(outDir, `narration-${String(index).padStart(2, "0")}.aiff`);
|
||||||
|
try {
|
||||||
|
await execFileAsync("say", ["-v", voice, "-o", file, text]);
|
||||||
|
const { stdout } = await execFileAsync("ffprobe", [
|
||||||
|
"-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "csv=p=0",
|
||||||
|
file,
|
||||||
|
]);
|
||||||
|
const durationMs = Math.round(parseFloat(stdout.trim()) * 1000);
|
||||||
|
return { path: file, durationMs };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Narration synthesis failed for "${text.slice(0, 40)}…": ${err}`);
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
cache.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
33
frontend/e2e/subtitles.ts
Normal file
33
frontend/e2e/subtitles.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface Clip {
|
||||||
|
title: string;
|
||||||
|
caption: string;
|
||||||
|
file: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
narration: { path: string; durationMs: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function msToSrtTimestamp(ms: number): string {
|
||||||
|
const clamped = Math.max(0, Math.round(ms));
|
||||||
|
const hours = Math.floor(clamped / 3_600_000);
|
||||||
|
const minutes = Math.floor((clamped % 3_600_000) / 60_000);
|
||||||
|
const seconds = Math.floor((clamped % 60_000) / 1000);
|
||||||
|
const millis = clamped % 1000;
|
||||||
|
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
||||||
|
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(millis, 3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One subtitle cue per step, spanning its full on-screen duration. */
|
||||||
|
export function buildSrt(clips: Clip[]): string {
|
||||||
|
return clips
|
||||||
|
.map((clip, i) => {
|
||||||
|
const index = i + 1;
|
||||||
|
return [
|
||||||
|
String(index),
|
||||||
|
`${msToSrtTimestamp(clip.startMs)} --> ${msToSrtTimestamp(clip.endMs)}`,
|
||||||
|
clip.caption,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
88
frontend/e2e/video-render.ts
Normal file
88
frontend/e2e/video-render.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { buildSrt, type Clip } from "./subtitles";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function hasFfmpeg() {
|
||||||
|
try {
|
||||||
|
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBTITLE_STYLE =
|
||||||
|
"FontSize=22,PrimaryColour=&H00FFFFFF,BackColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,MarginV=40";
|
||||||
|
|
||||||
|
function escapeForFilter(p: string) {
|
||||||
|
// ffmpeg filter arguments treat ':' and '\' specially — escape both.
|
||||||
|
return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderFinalVideo({
|
||||||
|
rawWebmPath,
|
||||||
|
clips,
|
||||||
|
outDir,
|
||||||
|
}: {
|
||||||
|
rawWebmPath: string;
|
||||||
|
clips: Clip[];
|
||||||
|
outDir: string;
|
||||||
|
}): Promise<{ videoFile: string; degraded: string[] }> {
|
||||||
|
const degraded: string[] = [];
|
||||||
|
const srtPath = path.join(outDir, "captions.srt");
|
||||||
|
fs.writeFileSync(srtPath, buildSrt(clips));
|
||||||
|
|
||||||
|
if (!hasFfmpeg()) {
|
||||||
|
const dest = path.join(outDir, "video.webm");
|
||||||
|
fs.renameSync(rawWebmPath, dest);
|
||||||
|
degraded.push("ffmpeg not found — kept raw video.webm, no subtitles burned in, no narration.");
|
||||||
|
return { videoFile: dest, degraded };
|
||||||
|
}
|
||||||
|
|
||||||
|
const narrated = clips.filter((c) => c.narration);
|
||||||
|
const mp4Path = path.join(outDir, "video.mp4");
|
||||||
|
const srtArg = `subtitles=${escapeForFilter(srtPath)}:force_style='${SUBTITLE_STYLE}'`;
|
||||||
|
|
||||||
|
if (narrated.length === 0) {
|
||||||
|
degraded.push("No narration available — burned-in subtitles only.");
|
||||||
|
await execFileAsync("ffmpeg", [
|
||||||
|
"-y", "-i", rawWebmPath,
|
||||||
|
"-vf", srtArg,
|
||||||
|
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
"-an",
|
||||||
|
mp4Path,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const audioInputs = narrated.flatMap((c) => ["-i", c.narration!.path]);
|
||||||
|
const adelayParts = narrated.map(
|
||||||
|
(c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.startMs))}|${Math.max(0, Math.round(c.startMs))}[a${i}]`,
|
||||||
|
);
|
||||||
|
const mixInputs = narrated.map((_, i) => `[a${i}]`).join("");
|
||||||
|
const filterComplex = [
|
||||||
|
`[0:v]${srtArg}[vout]`,
|
||||||
|
...adelayParts,
|
||||||
|
`${mixInputs}amix=inputs=${narrated.length}:normalize=0[aout]`,
|
||||||
|
].join(";");
|
||||||
|
|
||||||
|
await execFileAsync("ffmpeg", [
|
||||||
|
"-y", "-i", rawWebmPath, ...audioInputs,
|
||||||
|
"-filter_complex", filterComplex,
|
||||||
|
"-map", "[vout]", "-map", "[aout]",
|
||||||
|
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-shortest",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
mp4Path,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(rawWebmPath, { force: true });
|
||||||
|
for (const c of narrated) fs.rmSync(c.narration!.path, { force: true });
|
||||||
|
|
||||||
|
return { videoFile: mp4Path, degraded };
|
||||||
|
}
|
||||||
@@ -9,14 +9,16 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "tsx server.ts",
|
"preview": "tsx server.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"e2e": "playwright test",
|
||||||
|
"demo": "PW_DEMO=1 playwright test --grep @demo",
|
||||||
"gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts"
|
"gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ag-ui/client": "0.0.52",
|
"@ag-ui/client": "0.0.57",
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
"@copilotkit/react-core": "1.56.4",
|
"@copilotkit/react-core": "1.62.3",
|
||||||
"@copilotkit/react-ui": "1.56.4",
|
"@copilotkit/react-ui": "1.62.3",
|
||||||
"@copilotkit/runtime": "1.56.4",
|
"@copilotkit/runtime": "1.62.3",
|
||||||
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
|
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
|
||||||
"@fontsource-variable/noto-sans": "^5.2.10",
|
"@fontsource-variable/noto-sans": "^5.2.10",
|
||||||
"@hono/node-server": "^1.14.4",
|
"@hono/node-server": "^1.14.4",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/vite": "^4",
|
"@tailwindcss/vite": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
52
frontend/playwright.config.ts
Normal file
52
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
// Demo specs log in with the real dev credentials; pull them from the repo
|
||||||
|
// .env so they never live in the test source.
|
||||||
|
const repoEnv = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env");
|
||||||
|
if (fs.existsSync(repoEnv)) {
|
||||||
|
for (const line of fs.readFileSync(repoEnv, "utf8").split("\n")) {
|
||||||
|
const m = line.match(/^([A-Z_]+)=["']?(.*?)["']?$/);
|
||||||
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
outputDir: "./test-results",
|
||||||
|
fullyParallel: false,
|
||||||
|
// Demo runs synthesize narration and run several ffmpeg passes in save();
|
||||||
|
// that can exceed the 30s default, especially with many steps.
|
||||||
|
timeout: process.env.PW_DEMO ? 120_000 : 30_000,
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "playwright-report", open: "never" }],
|
||||||
|
],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
// Demo runs (pnpm demo) pace every action so the recorded video is
|
||||||
|
// watchable by a human; functional e2e runs stay at full speed.
|
||||||
|
launchOptions: {
|
||||||
|
slowMo: process.env.PW_DEMO ? 500 : 0,
|
||||||
|
},
|
||||||
|
locale: "es-CR",
|
||||||
|
timezoneId: "America/Costa_Rica",
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
// Retina-density rendering so doc screenshots stay crisp when zoomed.
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
// Full recordings are only useful for the paced demo. Keeping them off
|
||||||
|
// for regular e2e avoids making test success depend on artifact uploads.
|
||||||
|
video: process.env.PW_DEMO ? { mode: "on", size: { width: 1920, height: 1080 } } : "off",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
trace: process.env.PW_DEMO ? "on" : "on-first-retry",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
stdout: "ignore",
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
2054
frontend/pnpm-lock.yaml
generated
2054
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
|||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import {
|
import {
|
||||||
|
AgentRunner,
|
||||||
CopilotRuntime,
|
CopilotRuntime,
|
||||||
ExperimentalEmptyAdapter,
|
createCopilotRuntimeHandler,
|
||||||
copilotRuntimeNextJSAppRouterEndpoint,
|
} from "@copilotkit/runtime/v2";
|
||||||
} from "@copilotkit/runtime";
|
|
||||||
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
|
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
|
||||||
import { Observable } from "rxjs";
|
import { Observable, ReplaySubject } from "rxjs";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
const BACKEND_URL =
|
const BACKEND_URL =
|
||||||
@@ -120,6 +120,27 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) {
|
|||||||
const open = new Set<string>(); // tool call IDs currently in progress
|
const open = new Set<string>(); // tool call IDs currently in progress
|
||||||
const closed = new Set<string>(); // tool call IDs that already received END
|
const closed = new Set<string>(); // tool call IDs that already received END
|
||||||
|
|
||||||
|
// Render tools are display-once: after the client returns their result,
|
||||||
|
// the model sometimes re-issues the same call (fresh id) on the
|
||||||
|
// follow-up run, painting a second identical card. Track which render
|
||||||
|
// tools already ran in the current user turn (from the request's own
|
||||||
|
// message history) and suppress repeats — both their streamed events
|
||||||
|
// and their entries in this run's MESSAGES_SNAPSHOT.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const inputMessages: any[] = Array.isArray(input?.messages) ? input.messages : [];
|
||||||
|
let lastUserIdx = -1;
|
||||||
|
inputMessages.forEach((m, i) => { if (m?.role === "user") lastUserIdx = i; });
|
||||||
|
const renderedThisTurn = new Set<string>();
|
||||||
|
for (const m of inputMessages.slice(lastUserIdx + 1)) {
|
||||||
|
const calls = m?.toolCalls ?? m?.tool_calls;
|
||||||
|
if (m?.role !== "assistant" || !Array.isArray(calls)) continue;
|
||||||
|
for (const tc of calls) {
|
||||||
|
const name = tc?.function?.name;
|
||||||
|
if (name && RENDER_TOOLS.has(name)) renderedThisTurn.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const suppressed = new Set<string>(); // toolCallIds of dropped repeats
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return new Observable<any>((observer) => {
|
return new Observable<any>((observer) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -132,13 +153,51 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) {
|
|||||||
|
|
||||||
if (type === EventType.TOOL_CALL_START) {
|
if (type === EventType.TOOL_CALL_START) {
|
||||||
if (!id || closed.has(id) || open.has(id)) return; // duplicate
|
if (!id || closed.has(id) || open.has(id)) return; // duplicate
|
||||||
|
const name: string | undefined = event?.toolCallName;
|
||||||
|
if (name && RENDER_TOOLS.has(name)) {
|
||||||
|
if (renderedThisTurn.has(name)) {
|
||||||
|
suppressed.add(id);
|
||||||
|
return; // repeat render call — drop the whole call
|
||||||
|
}
|
||||||
|
renderedThisTurn.add(name);
|
||||||
|
}
|
||||||
open.add(id);
|
open.add(id);
|
||||||
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
|
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
|
||||||
|
if (id && suppressed.has(id)) return;
|
||||||
if (id && closed.has(id)) return; // already completed, drop
|
if (id && closed.has(id)) return; // already completed, drop
|
||||||
if (type === EventType.TOOL_CALL_END && id) {
|
if (type === EventType.TOOL_CALL_END && id) {
|
||||||
open.delete(id);
|
open.delete(id);
|
||||||
closed.add(id);
|
closed.add(id);
|
||||||
}
|
}
|
||||||
|
} else if (type === EventType.MESSAGES_SNAPSHOT && suppressed.size > 0) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
||||||
|
const cleaned = msgs
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.map((m: any) => {
|
||||||
|
const calls = m?.toolCalls ?? m?.tool_calls;
|
||||||
|
if (m?.role !== "assistant" || !Array.isArray(calls)) return m;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const kept = calls.filter((tc: any) => !suppressed.has(String(tc?.id ?? "")));
|
||||||
|
if (kept.length === calls.length) return m;
|
||||||
|
const { toolCalls: _a, tool_calls: _b, ...rest } = m;
|
||||||
|
return kept.length > 0 ? { ...rest, toolCalls: kept } : rest;
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.filter((m: any) => {
|
||||||
|
if (m?.role === "tool" && suppressed.has(String(m?.toolCallId ?? m?.tool_call_id ?? ""))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (m?.role === "assistant") {
|
||||||
|
const calls = m?.toolCalls ?? m?.tool_calls;
|
||||||
|
const hasCalls = Array.isArray(calls) && calls.length > 0;
|
||||||
|
const hasText = typeof m?.content === "string" && m.content.length > 0;
|
||||||
|
if (!hasCalls && !hasText) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
observer.next({ ...event, messages: cleaned });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
observer.next(event); // emit raw BaseEvent
|
observer.next(event); // emit raw BaseEvent
|
||||||
@@ -191,24 +250,25 @@ class StripModelArtifactsMiddleware extends (Middleware as any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ReconcileSnapshotMiddleware ──────────────────────────────────────────────
|
// ── DropRunMessagesSnapshotMiddleware ────────────────────────────────────────
|
||||||
// MAF's `_build_messages_snapshot` (agent_framework_ag_ui/_agent_run.py:686)
|
// MAF's run-end MESSAGES_SNAPSHOT re-mints ids for post-tool-call text (their
|
||||||
// mints a fresh UUID for the post-tool-call assistant text instead of reusing
|
// #3619) and, with thread persistence on, rebuilds history under STORE ids.
|
||||||
// the streamed TEXT_MESSAGE_START id. The resulting MESSAGES_SNAPSHOT then
|
// ag-ui's apply treats the snapshot as authoritative: client messages absent
|
||||||
// contains TWO assistant entries: the streamed id (holding the toolCalls) and
|
// from it are dropped, matching ids are replaced. Because streamed ids and
|
||||||
// a brand-new id (holding the duplicated text). ag-ui's snapshot merge replaces
|
// snapshot ids never agree for tool+text turns, every mid-session snapshot
|
||||||
// by id then APPENDS unknown ids, so the browser ends up with two assistant
|
// either duplicates the current answer (fresh-id append) or wipes earlier
|
||||||
// bubbles for the same answer. Dropping the snapshot entirely fixes the dupe
|
// ones (store-id replace with empty content) — both observed live. During a
|
||||||
// but breaks render_a2ui card persistence (cards rely on the snapshot to keep
|
// normal run the client already built the exact turn from streamed events,
|
||||||
// the assistant-with-toolCalls message in state past the run). The right fix
|
// so the snapshot adds nothing: swallow it. Hydration replays (empty
|
||||||
// is to drop just the orphan text-only assistant message that has no streamed
|
// input.messages) pass through untouched — there the snapshot IS the
|
||||||
// counterpart. Remove once `_agent_run.py:686` reuses `flow.message_id`.
|
// conversation, and the client converges to store ids on every reload.
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
class DropRunMessagesSnapshotMiddleware extends (Middleware as any) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
run(input: any, next: any): Observable<any> {
|
run(input: any, next: any): Observable<any> {
|
||||||
const streamedTextIds = new Set<string>();
|
const isHydration =
|
||||||
|
!Array.isArray(input?.messages) || input.messages.length === 0;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return new Observable<any>((observer) => {
|
return new Observable<any>((observer) => {
|
||||||
@@ -217,33 +277,9 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
next: (eventWithState: any) => {
|
next: (eventWithState: any) => {
|
||||||
const event = eventWithState.event;
|
const event = eventWithState.event;
|
||||||
const type: string = event?.type ?? "";
|
if (event?.type === EventType.MESSAGES_SNAPSHOT && !isHydration) {
|
||||||
|
return; // client state from streaming is already correct
|
||||||
if (type === EventType.TEXT_MESSAGE_START && event?.messageId) {
|
|
||||||
streamedTextIds.add(String(event.messageId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === EventType.MESSAGES_SNAPSHOT) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
|
||||||
const filtered = msgs.filter((m) => {
|
|
||||||
if (m?.role !== "assistant") return true;
|
|
||||||
const id = String(m?.id ?? "");
|
|
||||||
const hasText = typeof m?.content === "string" && m.content.length > 0;
|
|
||||||
const hasToolCalls =
|
|
||||||
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
|
|
||||||
(Array.isArray(m?.tool_calls) && m.tool_calls.length > 0);
|
|
||||||
if (hasText && !hasToolCalls && !streamedTextIds.has(id)) {
|
|
||||||
return false; // drop orphan text-only assistant duplicate
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (filtered.length !== msgs.length) {
|
|
||||||
observer.next({ ...event, messages: filtered });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
observer.next(event);
|
observer.next(event);
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -261,7 +297,7 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
|||||||
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards
|
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards
|
||||||
// them if any render tool call is detected in the same turn.
|
// them if any render tool call is detected in the same turn.
|
||||||
|
|
||||||
const RENDER_TOOLS = new Set(["render_a2ui", "render_spending_summary"]);
|
const RENDER_TOOLS = new Set(["render_spending_summary"]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
|
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
|
||||||
@@ -347,60 +383,193 @@ app.use("*", async (c, next) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.all("/api/copilotkit/*", async (c) => {
|
// ── CopilotKit v2 runtime (module scope, built once) ─────────────────────────
|
||||||
const cookieHeader = c.req.header("cookie") ?? "";
|
// Migrated 2026-07-04 off the deprecated copilotRuntimeNextJSAppRouterEndpoint
|
||||||
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
|
// wrapper (which was already the v2 single-route handler underneath) onto
|
||||||
const token = match?.[1];
|
// createCopilotRuntimeHandler directly, with a Postgres-backed AgentRunner:
|
||||||
const agentHeaders: Record<string, string> = token
|
// run() is a pure proxy to the MAF backend (which persists every turn in
|
||||||
? { Authorization: `Bearer ${token}` }
|
// chatthreadsnapshot), and connect() forwards to MAF's snapshot hydration —
|
||||||
: {};
|
// so the ONLY conversation store is the DB. This deletes the in-memory
|
||||||
|
// runner (whose process-local replay resurrected cleared conversations) and
|
||||||
|
// the client-side hydration hack that compensated for it.
|
||||||
|
|
||||||
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
|
// The v2 handler nags about anonymous telemetry unless disabled.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true";
|
||||||
|
|
||||||
|
function makeWealthysmartAgent(headers: Record<string, string> = {}): HttpAgent {
|
||||||
|
const agent = new HttpAgent({ url: AGENT_URL, headers });
|
||||||
|
// CK_MIDDLEWARES=off runs the stack bare — used to audit which of these
|
||||||
|
// workarounds are still needed after CopilotKit/MAF upgrades.
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
if (process.env.CK_MIDDLEWARES !== "off") {
|
||||||
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
agent.use(new StripModelArtifactsMiddleware() as any);
|
agent.use(new StripModelArtifactsMiddleware() as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
agent.use(new DropRunMessagesSnapshotMiddleware() as any);
|
||||||
agent.use(new ReconcileSnapshotMiddleware() as any);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
agent.use(new DeduplicateToolCallMiddleware() as any);
|
agent.use(new DeduplicateToolCallMiddleware() as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
}
|
||||||
|
if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") {
|
||||||
agent.use(new DebugLogMiddleware() as any);
|
agent.use(new DebugLogMiddleware() as any);
|
||||||
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PostgresAgentRunner ───────────────────────────────────────────────────────
|
||||||
|
// The conversation store is Postgres (chatthreadsnapshot), written by MAF at
|
||||||
|
// every run end. So the runner records NOTHING:
|
||||||
|
// - run() proxies the agent's event stream (the per-request clone the v2
|
||||||
|
// handler prepared, auth headers already forwarded onto it).
|
||||||
|
// - connect() asks MAF for its snapshot hydration — an empty-messages run
|
||||||
|
// returns RUN_STARTED → [STATE_SNAPSHOT] → MESSAGES_SNAPSHOT → RUN_FINISHED
|
||||||
|
// with no LLM call (unknown thread: RUN_STARTED → RUN_FINISHED). The same
|
||||||
|
// stream shape the in-memory runner's replay approximated, but sourced
|
||||||
|
// from the DB, so it is correct across clears, restarts and instances.
|
||||||
|
// - Live runs are tracked in-process only for stop(); the official
|
||||||
|
// sqlite-runner does the same (single-instance limitation acknowledged).
|
||||||
|
// isRunning() has no caller in the v2 SSE path (verified at 1.62.2).
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const liveRuns = new Map<string, any>();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function proxyAgentRun(agent: any, input: any, threadId: string): Observable<any> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const subject = new ReplaySubject<any>(Infinity);
|
||||||
|
liveRuns.set(threadId, agent);
|
||||||
|
agent
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.runAgent(input, { onEvent: ({ event }: any) => subject.next(event) })
|
||||||
|
.then(() => subject.complete())
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.catch((err: any) => subject.error(err))
|
||||||
|
.finally(() => {
|
||||||
|
if (liveRuns.get(threadId) === agent) liveRuns.delete(threadId);
|
||||||
|
});
|
||||||
|
return subject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
class PostgresAgentRunner extends (AgentRunner as any) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
run(request: any): Observable<any> {
|
||||||
|
return proxyAgentRun(request.agent, request.input, request.threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
connect(request: any): Observable<any> {
|
||||||
|
// request.headers = authorization + x-* from the incoming browser
|
||||||
|
// request (extractForwardableHeaders) — exactly what MAF needs.
|
||||||
|
const agent = makeWealthysmartAgent(request.headers ?? {});
|
||||||
|
agent.threadId = request.threadId;
|
||||||
|
// No messages + known threadId = MAF's hydration trigger.
|
||||||
|
return proxyAgentRun(agent, {}, `connect:${request.threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
isRunning(request: any): Promise<boolean> {
|
||||||
|
return Promise.resolve(liveRuns.has(request.threadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async stop(request: any): Promise<boolean | undefined> {
|
||||||
|
const agent = liveRuns.get(request.threadId);
|
||||||
|
if (!agent) return false;
|
||||||
|
try {
|
||||||
|
agent.abortRun();
|
||||||
|
liveRuns.delete(request.threadId);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const runtime = new CopilotRuntime({
|
const runtime = new CopilotRuntime({
|
||||||
agents: { wealthysmart: agent },
|
agents: { wealthysmart: makeWealthysmartAgent() },
|
||||||
a2ui: { injectA2UITool: true },
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
runner: new PostgresAgentRunner() as any,
|
||||||
|
// a2ui disabled 2026-07-04: CK 1.60+ validates generated ops against a
|
||||||
|
// catalog and our agent's ops fail validation (surface never paints).
|
||||||
|
// Markdown tables cover the use case until the pipeline matures.
|
||||||
|
a2ui: { enabled: false },
|
||||||
beforeRequestMiddleware: async ({ request: outbound }) => {
|
beforeRequestMiddleware: async ({ request: outbound }) => {
|
||||||
if (outbound.method !== "POST") return;
|
if (outbound.method !== "POST") return;
|
||||||
const ct = outbound.headers.get("content-type") ?? "";
|
const ct = outbound.headers.get("content-type") ?? "";
|
||||||
if (!ct.includes("application/json")) return;
|
if (!ct.includes("application/json")) return;
|
||||||
try {
|
try {
|
||||||
const body = (await outbound.clone().json()) as { messages?: AGUIMessage[] };
|
const body = (await outbound.clone().json()) as {
|
||||||
|
messages?: AGUIMessage[];
|
||||||
|
context?: { description?: string; value?: string }[];
|
||||||
|
};
|
||||||
if (!Array.isArray(body.messages)) return;
|
if (!Array.isArray(body.messages)) return;
|
||||||
const paired = pairOrphanToolCalls(body.messages);
|
// An empty messages array is MAF's hydration trigger (known threadId
|
||||||
if (paired.length === body.messages.length) return;
|
// + no messages → replay the stored snapshot without invoking the
|
||||||
|
// LLM). Injecting anything would turn it into a normal run.
|
||||||
|
if (body.messages.length === 0) return;
|
||||||
|
let messages = pairOrphanToolCalls(body.messages);
|
||||||
|
|
||||||
|
// MAF's AG-UI endpoint declares `context` on its request model but
|
||||||
|
// never feeds it to the model (agent_framework_ag_ui, checked at
|
||||||
|
// python-1.10.0). CopilotKit 1.60+ ships the A2UI catalog schema and
|
||||||
|
// guidelines in that context — without them the model emits ops the
|
||||||
|
// A2UI validator rejects and the surface never paints. Inject the
|
||||||
|
// context as a system message ourselves. Remove once MAF consumes
|
||||||
|
// RunAgentInput.context.
|
||||||
|
if (Array.isArray(body.context) && body.context.length > 0) {
|
||||||
|
const contextText = body.context
|
||||||
|
.map((c) => `## ${c.description ?? "Context"}\n${c.value ?? ""}`)
|
||||||
|
.join("\n\n");
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
id: `ctx-${Date.now()}`,
|
||||||
|
role: "system",
|
||||||
|
content: `Additional run context:\n\n${contextText}`,
|
||||||
|
} as AGUIMessage,
|
||||||
|
...messages,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages === body.messages) return;
|
||||||
return new Request(outbound.url, {
|
return new Request(outbound.url, {
|
||||||
method: outbound.method,
|
method: outbound.method,
|
||||||
headers: outbound.headers,
|
headers: outbound.headers,
|
||||||
body: JSON.stringify({ ...body, messages: paired }),
|
body: JSON.stringify({ ...body, messages }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Skipping the repair is survivable; doing it silently is not —
|
// Skipping the repair is survivable; doing it silently is not —
|
||||||
// orphan tool calls then fail downstream with no trace of why.
|
// orphan tool calls then fail downstream with no trace of why.
|
||||||
console.error("[copilotkit] orphan-tool-call repair skipped:", err);
|
console.error("[copilotkit] outbound request repair skipped:", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
const ckHandler = createCopilotRuntimeHandler({
|
||||||
runtime,
|
runtime,
|
||||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
basePath: "/api/copilotkit",
|
||||||
endpoint: "/api/copilotkit",
|
// Single-route mode: same envelope protocol the client already resolved
|
||||||
|
// via auto-detect (its GET /info probe 405s here, so it stays "single").
|
||||||
|
// Multi-route would only add /threads* REST endpoints we don't use and is
|
||||||
|
// exposed to CopilotKit#4953 (frontend tools under multi-route).
|
||||||
|
mode: "single-route",
|
||||||
|
cors: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
|
// The v2 handler forwards only `authorization` and `x-*` headers to agents
|
||||||
});
|
// and to runner.connect — cookies never pass. Translate the auth cookie into
|
||||||
|
// a Bearer header before the handler sees the request.
|
||||||
|
function withBearerFromCookie(req: Request): Request {
|
||||||
|
if (req.headers.get("authorization")) return req;
|
||||||
|
const cookie = req.headers.get("cookie") ?? "";
|
||||||
|
const match = cookie.match(/(?:^|;\s*)ws_token=([^;]+)/);
|
||||||
|
if (!match) return req;
|
||||||
|
const headers = new Headers(req.headers);
|
||||||
|
headers.set("Authorization", `Bearer ${match[1]}`);
|
||||||
|
return new Request(req, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.all("/api/copilotkit/*", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
|
||||||
|
app.all("/api/copilotkit", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
|
||||||
|
|
||||||
app.get("/api/health", (c) => c.json({ ok: true }));
|
app.get("/api/health", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,17 @@ export default function App() {
|
|||||||
<PrivacyProvider>
|
<PrivacyProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
|
<CopilotKit
|
||||||
|
runtimeUrl="/api/copilotkit"
|
||||||
|
agent="wealthysmart"
|
||||||
|
headers={{
|
||||||
|
// Lets the agent resolve "hoy"/"este mes" in the user's
|
||||||
|
// local time wherever they are; backend falls back to
|
||||||
|
// Costa Rica when absent.
|
||||||
|
"X-Client-Timezone":
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
<Toaster richColors position="top-right" closeButton />
|
<Toaster richColors position="top-right" closeButton />
|
||||||
</CopilotKit>
|
</CopilotKit>
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ export default function Layout() {
|
|||||||
const { privacyMode, togglePrivacy } = usePrivacy();
|
const { privacyMode, togglePrivacy } = usePrivacy();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const title = titleFor(pathname);
|
const title = titleFor(pathname);
|
||||||
|
// Asistente hosts its own scroll container (the chat), so its route caps
|
||||||
|
// the shell at the viewport; every other page keeps normal body scroll.
|
||||||
|
const fullHeight = pathname === "/asistente";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset className={fullHeight ? "h-svh overflow-hidden" : undefined}>
|
||||||
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background/90 px-4 backdrop-blur-sm">
|
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background/90 px-4 backdrop-blur-sm">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator
|
<Separator
|
||||||
|
|||||||
177
frontend/src/components/SalaryEntryDialog.tsx
Normal file
177
frontend/src/components/SalaryEntryDialog.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { formatLocalDatetime } from '@/lib/format';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalaryEntryDialog({ onClose, onSaved }: Props) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
amount: '',
|
||||||
|
currency: 'CRC',
|
||||||
|
date: formatLocalDatetime(new Date()),
|
||||||
|
bank: 'BAC',
|
||||||
|
reference: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const amount = Number(form.amount);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
setError('Ingresá un monto mayor que cero.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.post('/transactions/', {
|
||||||
|
amount,
|
||||||
|
currency: form.currency,
|
||||||
|
date: form.date,
|
||||||
|
bank: form.bank,
|
||||||
|
merchant: 'Salario',
|
||||||
|
transaction_type: 'SALARY',
|
||||||
|
source: 'TRANSFER',
|
||||||
|
reference: form.reference.trim() || null,
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
});
|
||||||
|
toast.success('Salario registrado');
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const status = err && typeof err === 'object' && 'response' in err
|
||||||
|
? (err as { response: { status: number } }).response.status
|
||||||
|
: null;
|
||||||
|
setError(
|
||||||
|
status === 409
|
||||||
|
? 'Ya existe una transacción con ese comprobante.'
|
||||||
|
: 'No se pudo registrar el salario. Intentá de nuevo.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Registrar salario</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Guardá el depósito recibido mientras configurás una nueva automatización.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="salary-amount">Monto</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-amount"
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.amount}
|
||||||
|
onChange={(e) => setForm({ ...form, amount: e.target.value })}
|
||||||
|
placeholder="0.00"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Moneda</Label>
|
||||||
|
<Select value={form.currency} onValueChange={(value) => value && setForm({ ...form, currency: value })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="CRC">CRC (₡)</SelectItem>
|
||||||
|
<SelectItem value="USD">USD ($)</SelectItem>
|
||||||
|
<SelectItem value="EUR">EUR (€)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label htmlFor="salary-date">Fecha del depósito</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-date"
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Banco</Label>
|
||||||
|
<Select value={form.bank} onValueChange={(value) => value && setForm({ ...form, bank: value })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="BAC">BAC</SelectItem>
|
||||||
|
<SelectItem value="BCR">BCR</SelectItem>
|
||||||
|
<SelectItem value="DAVIVIENDA">Davivienda</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="salary-reference">Comprobante</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-reference"
|
||||||
|
value={form.reference}
|
||||||
|
onChange={(e) => setForm({ ...form, reference: e.target.value })}
|
||||||
|
placeholder="Opcional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label htmlFor="salary-notes">Nota</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="Ej. Quincena de julio"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Registrar salario'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,7 +76,10 @@ export function SpendingSummaryCard({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
|
<div
|
||||||
|
data-testid="spending-summary-card"
|
||||||
|
className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -345,6 +345,23 @@ export interface paths {
|
|||||||
patch: operations["update_category_api_v1_categories__category_id__patch"];
|
patch: operations["update_category_api_v1_categories__category_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/chat/thread": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Clear Thread */
|
||||||
|
delete: operations["clear_thread_api_v1_chat_thread_delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/exchange-rate/": {
|
"/api/v1/exchange-rate/": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -898,11 +915,9 @@ export interface components {
|
|||||||
AGUIRequest: {
|
AGUIRequest: {
|
||||||
/**
|
/**
|
||||||
* Availableinterrupts
|
* Availableinterrupts
|
||||||
* @description List of interrupts that can be resumed by the server
|
* @description Canonical AG-UI interrupts that can be resumed by the server
|
||||||
*/
|
*/
|
||||||
availableInterrupts?: {
|
availableInterrupts?: components["schemas"]["Interrupt"][] | null;
|
||||||
[key: string]: unknown;
|
|
||||||
}[] | null;
|
|
||||||
/**
|
/**
|
||||||
* Context
|
* Context
|
||||||
* @description List of context objects provided to the agent
|
* @description List of context objects provided to the agent
|
||||||
@@ -931,11 +946,9 @@ export interface components {
|
|||||||
parent_run_id?: string | null;
|
parent_run_id?: string | null;
|
||||||
/**
|
/**
|
||||||
* Resume
|
* Resume
|
||||||
* @description Resume payload containing interrupt responses
|
* @description Resume payload for continuing interrupted runs
|
||||||
*/
|
*/
|
||||||
resume?: {
|
resume?: components["schemas"]["ResumeEntry"][] | null;
|
||||||
[key: string]: unknown;
|
|
||||||
} | null;
|
|
||||||
/**
|
/**
|
||||||
* Run Id
|
* Run Id
|
||||||
* @description Optional run identifier for tracking
|
* @description Optional run identifier for tracking
|
||||||
@@ -1200,6 +1213,11 @@ export interface components {
|
|||||||
/** Name */
|
/** Name */
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
};
|
};
|
||||||
|
/** ClearThreadResponse */
|
||||||
|
ClearThreadResponse: {
|
||||||
|
/** Cleared */
|
||||||
|
cleared: boolean;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* Currency
|
* Currency
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
@@ -1351,6 +1369,34 @@ export interface components {
|
|||||||
/** Num Installments */
|
/** Num Installments */
|
||||||
num_installments?: number | null;
|
num_installments?: number | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Interrupt
|
||||||
|
* @description A pause carried inside ``RunFinishedEvent.outcome`` when the outcome is
|
||||||
|
* ``RunFinishedInterruptOutcome``. The client resumes
|
||||||
|
* by addressing this interrupt in the resume array of the next RunAgentInput.
|
||||||
|
*/
|
||||||
|
Interrupt: {
|
||||||
|
/** Expiresat */
|
||||||
|
expiresAt?: string | null;
|
||||||
|
/** Id */
|
||||||
|
id: string;
|
||||||
|
/** Message */
|
||||||
|
message?: string | null;
|
||||||
|
/** Metadata */
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
/** Reason */
|
||||||
|
reason: string;
|
||||||
|
/** Responseschema */
|
||||||
|
responseSchema?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
/** Toolcallid */
|
||||||
|
toolCallId?: string | null;
|
||||||
|
} & {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
/** LoginRequest */
|
/** LoginRequest */
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
/** Password */
|
/** Password */
|
||||||
@@ -1809,6 +1855,23 @@ export interface components {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* ResumeEntry
|
||||||
|
* @description A per-interrupt response in the resume array of a RunAgentInput.
|
||||||
|
*/
|
||||||
|
ResumeEntry: {
|
||||||
|
/** Interruptid */
|
||||||
|
interruptId: string;
|
||||||
|
/** Payload */
|
||||||
|
payload?: unknown | null;
|
||||||
|
/**
|
||||||
|
* Status
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
status: "resolved" | "cancelled";
|
||||||
|
} & {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
/** SalariosSummary */
|
/** SalariosSummary */
|
||||||
SalariosSummary: {
|
SalariosSummary: {
|
||||||
/** Count */
|
/** Count */
|
||||||
@@ -3023,6 +3086,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
clear_thread_api_v1_chat_thread_delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
authorization?: string | null;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
cookie?: {
|
||||||
|
ws_token?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ClearThreadResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
current_rate_api_v1_exchange_rate__get: {
|
current_rate_api_v1_exchange_rate__get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
CopilotChat,
|
CopilotChat,
|
||||||
@@ -7,10 +7,18 @@ import {
|
|||||||
useCopilotKit,
|
useCopilotKit,
|
||||||
} from "@copilotkit/react-core/v2";
|
} from "@copilotkit/react-core/v2";
|
||||||
import { useCopilotAction } from "@copilotkit/react-core";
|
import { useCopilotAction } from "@copilotkit/react-core";
|
||||||
import { Sparkles } from "lucide-react";
|
import { MessageSquarePlus, Sparkles } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
||||||
|
|
||||||
|
// Single persistent conversation: the backend snapshot store keys on this
|
||||||
|
// same id (backend/app/api/v1/endpoints/chat.py). An explicit, stable
|
||||||
|
// threadId also stops CopilotChat from wiping messages on remount.
|
||||||
|
const CHAT_THREAD_ID = "main";
|
||||||
|
|
||||||
const STATIC_SUGGESTIONS = {
|
const STATIC_SUGGESTIONS = {
|
||||||
available: "before-first-message" as const,
|
available: "before-first-message" as const,
|
||||||
suggestions: [
|
suggestions: [
|
||||||
@@ -24,28 +32,74 @@ const STATIC_SUGGESTIONS = {
|
|||||||
export default function Asistente() {
|
export default function Asistente() {
|
||||||
useConfigureSuggestions(STATIC_SUGGESTIONS);
|
useConfigureSuggestions(STATIC_SUGGESTIONS);
|
||||||
|
|
||||||
// A question typed on the Inicio dashboard arrives via router state:
|
|
||||||
// append it as a user message and run the agent once.
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { agent } = useAgent({ agentId: "wealthysmart" });
|
const { agent } = useAgent({ agentId: "wealthysmart" });
|
||||||
const { copilotkit } = useCopilotKit();
|
const { copilotkit } = useCopilotKit();
|
||||||
const sentRef = useRef(false);
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
|
||||||
|
// Ask-box flow: a question typed on Inicio arrives via router state.
|
||||||
|
// History hydration is no longer done here — CopilotChat's mount connect
|
||||||
|
// replays the persisted thread through the BFF's PostgresAgentRunner. We
|
||||||
|
// still await our own connect before sending so the run's input carries
|
||||||
|
// the full client-known history (idempotent with CopilotChat's connect:
|
||||||
|
// the MESSAGES_SNAPSHOT merge is id-based).
|
||||||
|
const bootRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ask = (location.state as { ask?: string } | null)?.ask;
|
const ask = (location.state as { ask?: string } | null)?.ask;
|
||||||
if (!ask || sentRef.current || !agent) return;
|
if (!ask || !agent || bootRef.current) return;
|
||||||
sentRef.current = true;
|
bootRef.current = true;
|
||||||
// Clear the state so back/refresh doesn't re-send the question.
|
// Clear the state so back/refresh doesn't re-send the question.
|
||||||
navigate(location.pathname, { replace: true, state: null });
|
navigate(location.pathname, { replace: true, state: null });
|
||||||
|
void (async () => {
|
||||||
|
agent.threadId = CHAT_THREAD_ID;
|
||||||
|
if (agent.messages.length === 0) {
|
||||||
|
try {
|
||||||
|
await copilotkit.connectAgent({ agent });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[asistente] connect before ask failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
agent.addMessage({
|
agent.addMessage({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
role: "user",
|
role: "user",
|
||||||
content: ask,
|
content: ask,
|
||||||
});
|
});
|
||||||
void copilotkit.runAgent({ agent });
|
void copilotkit.runAgent({ agent });
|
||||||
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [agent]);
|
}, [agent]);
|
||||||
|
|
||||||
|
const clearThread = async () => {
|
||||||
|
setClearing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/chat/thread", {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`DELETE /chat/thread → ${res.status}`);
|
||||||
|
}
|
||||||
|
// NOTE: there is no official flush for the runtime's in-memory thread
|
||||||
|
// store at CK 1.62 — the single-route endpoint we mount only accepts
|
||||||
|
// agent/run|connect|stop, info, transcribe; the threads/* router
|
||||||
|
// (incl. threads/clear) is a separate entrypoint this adapter doesn't
|
||||||
|
// serve. The BFF's connect intercept (server.ts) is what keeps that
|
||||||
|
// process memory from ever reaching the UI.
|
||||||
|
// Full reload is the reliable reset: CopilotKit keeps chat state in
|
||||||
|
// more places than agent.messages (clearing it in place left the
|
||||||
|
// conversation rendered), while a fresh boot hydrates from the
|
||||||
|
// now-empty store and shows the welcome screen + suggestions.
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[asistente] clear thread failed:", err);
|
||||||
|
toast.error("No se pudo borrar la conversación. Intenta de nuevo.");
|
||||||
|
setClearing(false);
|
||||||
|
setConfirmClear(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useCopilotAction({
|
useCopilotAction({
|
||||||
name: "render_spending_summary",
|
name: "render_spending_summary",
|
||||||
description:
|
description:
|
||||||
@@ -92,12 +146,43 @@ export default function Asistente() {
|
|||||||
icon={Sparkles}
|
icon={Sparkles}
|
||||||
title="Asistente"
|
title="Asistente"
|
||||||
subtitle="Pregúntale a WealthySmart sobre tus finanzas."
|
subtitle="Pregúntale a WealthySmart sobre tus finanzas."
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfirmClear(true)}
|
||||||
|
// Also disabled mid-run: MAF saves the thread at run END, so
|
||||||
|
// clearing while an answer streams would delete the row only
|
||||||
|
// to have the finishing run write it right back.
|
||||||
|
disabled={!agent || agent.messages.length === 0 || agent.isRunning}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus />
|
||||||
|
Nueva conversación
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{confirmClear && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="¿Nueva conversación?"
|
||||||
|
message="Se borrará el historial del chat guardado. Esta acción no se puede deshacer."
|
||||||
|
confirmLabel="Borrar historial"
|
||||||
|
onConfirm={clearThread}
|
||||||
|
onCancel={() => setConfirmClear(false)}
|
||||||
|
loading={clearing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
|
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
|
||||||
<CopilotChat
|
<CopilotChat
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
// Without an explicit agentId, CopilotChat resolves DEFAULT_AGENT_ID
|
||||||
|
// ("default") — a DIFFERENT client agent instance than
|
||||||
|
// useAgent({agentId:"wealthysmart"}); clearing one leaves the other
|
||||||
|
// rendering its own copy of the conversation.
|
||||||
|
agentId="wealthysmart"
|
||||||
|
threadId={CHAT_THREAD_ID}
|
||||||
labels={{
|
labels={{
|
||||||
modalHeaderTitle: "WealthySmart",
|
modalHeaderTitle: "WealthySmart",
|
||||||
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
|
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { type ColumnDef } from '@tanstack/react-table';
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { Landmark, RefreshCw, Download } from 'lucide-react';
|
import { Landmark, RefreshCw, Download, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { type Transaction, getSalarios, getSalariosSummary } from '@/lib/api';
|
import { type Transaction, getSalarios, getSalariosSummary } from '@/lib/api';
|
||||||
import { formatAmount, formatDate } from '@/lib/format';
|
import { formatAmount, formatDate } from '@/lib/format';
|
||||||
@@ -13,8 +13,10 @@ import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import SalaryEntryDialog from '@/components/SalaryEntryDialog';
|
||||||
|
|
||||||
export default function Salarios() {
|
export default function Salarios() {
|
||||||
|
const [entryOpen, setEntryOpen] = useState(false);
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['salarios'],
|
queryKey: ['salarios'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -95,6 +97,10 @@ export default function Salarios() {
|
|||||||
subtitle="Historial de depósitos salariales"
|
subtitle="Historial de depósitos salariales"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
<Button size="sm" onClick={() => setEntryOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Registrar salario
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -152,6 +158,12 @@ export default function Salarios() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{entryOpen && (
|
||||||
|
<SalaryEntryDialog
|
||||||
|
onClose={() => setEntryOpen(false)}
|
||||||
|
onSaved={() => { void query.refetch(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import react from "@vitejs/plugin-react-oxc";
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
// Browser requests arrive at Vite, which then proxies them to the backend.
|
||||||
|
// Locally the backend is published on the host; Docker Compose supplies the
|
||||||
|
// service hostname instead (see docker-compose.yml).
|
||||||
|
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8001";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -19,7 +24,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
// All other API calls → Python backend
|
// All other API calls → Python backend
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:8001",
|
target: backendUrl,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user