Compare commits

..

8 Commits

Author SHA1 Message Date
Carlos Escalante
3d0547743f Log client details for API and agent requests
All checks were successful
Deploy to VPS / test (push) Successful in 1m42s
Deploy to VPS / deploy (push) Successful in 14s
2026-07-14 22:55:50 -06:00
Carlos Escalante
8ceed0ab2e Add individual salary deposit agent tool
All checks were successful
Deploy to VPS / test (push) Successful in 1m43s
Deploy to VPS / deploy (push) Successful in 27s
2026-07-14 22:41:53 -06:00
Carlos Escalante
ca3115e99c Add retained chat and API request diagnostics 2026-07-14 22:34:33 -06:00
Carlos Escalante
3518ff58c4 Read assistant model from deployment secret
All checks were successful
Deploy to VPS / test (push) Successful in 1m49s
Deploy to VPS / deploy (push) Successful in 8s
2026-07-14 22:03:53 -06:00
Carlos Escalante
352b77ea07 Regenerate API types for MAF upgrade
All checks were successful
Deploy to VPS / test (push) Successful in 1m44s
Deploy to VPS / deploy (push) Successful in 2m29s
2026-07-14 21:52:50 -06:00
Carlos Escalante
fbc0816be6 Add Playwright assistant regression coverage
Some checks failed
Deploy to VPS / test (push) Failing after 1m37s
Deploy to VPS / deploy (push) Has been skipped
2026-07-14 21:48:59 -06:00
Carlos Escalante
301a0b687a Use Responses API with Luna by default 2026-07-14 21:48:54 -06:00
Carlos Escalante
b57a899634 Upgrade agent and CopilotKit dependencies 2026-07-14 21:48:40 -06:00
32 changed files with 2339 additions and 1044 deletions

View File

@@ -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

View File

@@ -51,7 +51,7 @@ jobs:
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }} VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }} VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
AGENT_MODEL=gpt-5.4-mini AGENT_MODEL=${{ secrets.AGENT_MODEL }}
ENVEOF ENVEOF
sed -i 's/^[[:space:]]*//' .env.prod sed -i 's/^[[:space:]]*//' .env.prod

5
.gitignore vendored
View File

@@ -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/

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
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
@@ -35,6 +35,9 @@ How to answer:
"ayer", "el martes", a specific date) need get_daily_spending with that "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 day's bounds — cycle or category summaries have NO per-day data, so never
answer a day question from them. 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 - 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 is not an answer — show what it is made of, right away, without waiting
to be asked: to be asked:
@@ -83,7 +86,9 @@ Generative UI — render tools:
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,
) )

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

View File

@@ -25,6 +25,7 @@ from agent_framework_ag_ui import AGUIThreadSnapshot
from sqlmodel import Session, select from sqlmodel import Session, select
from app.db import engine 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.models.models import ChatThreadSnapshot
from app.timeutil import utcnow from app.timeutil import utcnow
@@ -130,6 +131,14 @@ class PostgresSnapshotStore:
row.interrupt = snapshot.interrupt row.interrupt = snapshot.interrupt
row.updated_at = utcnow() row.updated_at = utcnow()
session.add(row) 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() session.commit()
await asyncio.to_thread(_save) await asyncio.to_thread(_save)

View File

@@ -48,6 +48,7 @@ from app.services.exchange_rate import (
) )
_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:
@@ -339,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
@@ -347,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,
@@ -567,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,

View File

@@ -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
View 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,
)

View File

@@ -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,14 @@ 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.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
@@ -25,9 +34,45 @@ 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.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:
@@ -101,17 +146,24 @@ def _drop_stale_client_history(session, thread_id, messages: list) -> list:
@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)
@@ -125,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
@@ -159,6 +272,7 @@ async def agent_auth_and_session(request: Request, call_next):
session = next(session_gen) session = next(session_gen)
token_var = set_session(session) 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()
@@ -172,16 +286,75 @@ async def agent_auth_and_session(request: Request, call_next):
) )
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 # Browser's IANA timezone, forwarded by the BFF — lets tools resolve
# "today" wherever the user is (falls back to Costa Rica). # "today" wherever the user is (falls back to Costa Rica).
tz_token = set_client_timezone(request.headers.get("x-client-timezone")) tz_token = set_client_timezone(request.headers.get("x-client-timezone"))
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_client_timezone(tz_token)
reset_session(token_var) reset_session(token_var)
try: try:
@@ -190,6 +363,12 @@ 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)

View File

@@ -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
@@ -566,3 +566,38 @@ class ChatThreadSnapshot(SQLModel, table=True):
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True) default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
) )
updated_at: datetime = Field(default_factory=utcnow) 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)

View File

@@ -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.10.0 agent-framework==1.11.0
agent-framework-a2a==1.0.0b260428 agent-framework-a2a==1.0.0b260428
agent-framework-ag-ui==1.0.0rc7 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.10.0 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.10.0 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

View File

@@ -11,6 +11,6 @@ httpx
pywebpush pywebpush
py-vapid py-vapid
python-dateutil python-dateutil
agent-framework==1.10.0 agent-framework==1.11.0
agent-framework-ag-ui==1.0.0rc7 agent-framework-ag-ui==1.0.0rc8
agent-framework-openai==1.10.0 agent-framework-openai==1.10.1

View 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"

View File

@@ -17,6 +17,7 @@ from app.models.models import (
MunicipalReceipt, MunicipalReceipt,
PensionSnapshot, PensionSnapshot,
Transaction, Transaction,
TransactionType,
WaterMeterReading, WaterMeterReading,
) )
@@ -173,6 +174,52 @@ def test_recent_transactions_exclude_future_cuotas(bound_session):
assert "FUTURE CUOTA:03/06" not 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(): def test_get_current_date_is_costa_rica_and_cycle_consistent():
from app.timeutil import today_cr from app.timeutil import today_cr

View 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

View File

@@ -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,7 +36,7 @@ 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. # MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
ENABLE_INSTRUMENTATION: "false" ENABLE_INSTRUMENTATION: "false"
expose: expose:
@@ -51,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:
@@ -84,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:

View File

@@ -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,7 @@ 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. # MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
ENABLE_INSTRUMENTATION: "false" ENABLE_INSTRUMENTATION: "false"
ports: ports:
@@ -40,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
@@ -66,6 +76,11 @@ services:
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

View 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
View 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,
"",
`![${s.title}](${s.file})`,
"",
]),
].join("\n");
fs.writeFileSync(path.join(this.dir, "index.md"), md);
}
}

View 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
View 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
View 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");
}

View 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 };
}

View File

@@ -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.57", "@ag-ui/client": "0.0.57",
"@base-ui/react": "^1.4.1", "@base-ui/react": "^1.4.1",
"@copilotkit/react-core": "1.62.2", "@copilotkit/react-core": "1.62.3",
"@copilotkit/react-ui": "1.62.2", "@copilotkit/react-ui": "1.62.3",
"@copilotkit/runtime": "1.62.2", "@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",

View 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,
},
});

1881
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -915,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
@@ -948,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
@@ -1373,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 */
@@ -1831,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 */