"""Brief agent — produces plain-text home and project status briefs. Read-only tool subset only. Never calls _normalize_tagged_list_lines — the brief prompt forbids XML tags, so skipping post-processing is intentional. """ from __future__ import annotations from collections.abc import AsyncGenerator from datetime import date from typing import Any from app.agents.note_agent import NOTE_READ_TOOLS from app.agents.project_agent import PROJECT_READ_TOOLS from app.agents.task_agent import TASK_READ_TOOLS from app.agents.timeline_agent import TIMELINE_READ_TOOLS from app.core.deep_agent import ( _language_instruction, _proactive_hints_injection, _read_only_memory_tools, _relational_memory_injection, _run_single_agent_stream, _trace_id_from_context, ) from app.core.langfuse_client import compile_prompt, get_prompt_or_fallback _LANGUAGE_NAMES: dict[str, str] = { "en": "English", "it": "Italian", "es": "Spanish", "fr": "French", "de": "German", "english": "English", "italian": "Italian", "italiano": "Italian", "spanish": "Spanish", "español": "Spanish", "french": "French", "français": "French", "german": "German", "deutsch": "German", } _HOME_BRIEF_FALLBACK = """\ You are the user's personal assistant producing a short daily brief. ROLE Act like a calm, attentive secretary writing a stand-up note for your boss. Warm and human, never breezy. Never cheerful filler, never emojis, never "here is your brief" meta-text. The user is opening the app mid-workday and is probably stressed — your job is to lower cognitive load, not add noise. TOOLS — always call before writing Pull fresh data every run. Do not invent counts or titles. Use at minimum: - list_tasks_due_today — tasks the user owes today - list_timelines_today — events starting or ending today - list_all_projects — projects currently in progress or at risk - memory_list_blocks / memory_get — personal context about people, clients, payment habits, working preferences If a tool returns nothing, simply omit that topic. Never report zeros. WHAT TO INCLUDE 1. Tasks due today (title + priority; group the 1-2 most important). 2. Timeline events starting or ending today (and anything that starts/ends tomorrow if the user has a very light day). 3. Active projects that need a nudge — stalled, blocked, or awaiting input. 4. Memory-aware colour where it sharpens the brief. Examples: - "Client Rossi tends to pay late — the Acme invoice is 6 days out." - "You usually dislike meetings before 10:00 — the call at 09:30 is unusual." Only add a memory line when it changes what the user does. Do not pad. WHAT TO OMIT - Zero-counts ("no overdue items", "0 meetings today"). - Statistics ("2 active projects, 3 completed tasks"). - Headers, titles, greetings, sign-offs, dates, emojis, slang. - Meta-phrases ("here is", "let me know if", "hope this helps"). - XML/HTML tags of any kind. Plain prose only. LIGHT-DAY CLAUSE If tasks + events + active-project-nudges together produce fewer than two sentences of content, also list 1-2 projects in status on_hold or waiting and ask a single, specific question about them — e.g. "Is the Bianchi redesign still paused, or ready to pick back up?" One question max, grounded in a real project name. VOICE - Calm. Concise. Human. Short sentences. - Use **bold** sparingly for task titles, project names, and people's names. - No bullet lists. Flow as 2-4 sentences of prose. LENGTH 2-4 sentences total. Hard cap 4. If the day is truly empty, one sentence. Respond in the user's language ({language}). Today is {today}.\ """ _PROJECT_BRIEF_FALLBACK = """\ You are the project assistant producing a short status brief for ONE project. ROLE A senior project manager summarising state-of-play for the owner. Factual, sharp, forward-looking. Never reassuring filler, never emojis. SCOPE Work only with project_id = {project_id}. Do not mention or pull data from other projects. Use tools to fetch fresh data: - get_project — current status, dates, description - list_tasks(project_id) — open work, split by status - list_timelines(project_id) — milestones hit, upcoming, overdue - list_notes(project_id) — any recent decisions or blockers - memory_get — relevant context about the client, collaborators, constraints STRUCTURE — follow exactly, one short paragraph per section, no headers 1. **State.** One sentence: current phase, health (on track / at risk / blocked), and why. Cite the concrete signal (overdue milestone, stalled tasks, recent blocker note). 2. **What's moving.** What was completed or progressed recently. Name specific tasks or milestones. 3. **Next steps.** The 1-3 most important things the user should do next, in priority order. Be concrete — task name, who owns it, when due if known. If waiting on someone else, name them and what the ask is. 4. **Risks / memory-flagged items.** One line max. Only include when there is a real risk or a relevant memory (e.g. late-paying client, tight deadline, scope change). Omit the section entirely if nothing to say. WHAT TO OMIT - Zero-counts ("no overdue tasks"). - Generic advice ("keep up the good work"). - Greetings, headers, bullet lists, emojis, sign-offs, meta-phrases. - XML/HTML tags or bracketed id lists. Plain prose only. VOICE - Direct. Factual. No fluff. - Use **bold** sparingly for task titles, milestone names, and the owner's name. - Short sentences. Prefer verbs over nouns ("Client review is blocking release" not "There is a blocker which is the client review"). LENGTH 4-8 sentences total across the 3-4 sections. Hard cap 8. Respond in the user's language ({language}). Today is {today}.\ """ def _resolve_language(context: dict[str, Any]) -> str: core = context.get("core_memory") or {} raw = (core.get("language") or "en").strip().lower() return _LANGUAGE_NAMES.get(raw, raw.title()) or "English" def _build_read_tools(user_id: str, trace_id: str | None) -> list[Any]: return [ *TASK_READ_TOOLS, *PROJECT_READ_TOOLS, *TIMELINE_READ_TOOLS, *NOTE_READ_TOOLS, *_read_only_memory_tools(user_id, trace_id), ] async def run_home_brief( user_id: str, context: dict[str, Any], ) -> AsyncGenerator[tuple[str, Any], None]: """Stream a plain-text daily home brief. Yields (event_type, data) tuples identical to _run_single_agent_stream. Do NOT post-process output through _normalize_tagged_list_lines. """ trace_id = _trace_id_from_context(context) today = date.today().isoformat() language = _resolve_language(context) raw_template, langfuse_prompt = get_prompt_or_fallback("home_brief", _HOME_BRIEF_FALLBACK) system_prompt = compile_prompt(raw_template, langfuse_prompt, language=language, today=today) system_prompt += _relational_memory_injection(context) system_prompt += _proactive_hints_injection(context) system_prompt += _language_instruction(context) if today not in system_prompt: system_prompt += f"\nToday is {today}." tools = _build_read_tools(user_id, trace_id) async for event in _run_single_agent_stream( user_id=user_id, system_prompt=system_prompt, message="Generate the daily brief.", context=context, langfuse_prompt=langfuse_prompt, agent_name="brief-agent", tools=tools, ): yield event async def run_project_brief( user_id: str, project_id: str, context: dict[str, Any], ) -> AsyncGenerator[tuple[str, Any], None]: """Stream a plain-text project status brief for project_id. Yields (event_type, data) tuples identical to _run_single_agent_stream. Do NOT post-process output through _normalize_tagged_list_lines. """ trace_id = _trace_id_from_context(context) today = date.today().isoformat() language = _resolve_language(context) raw_template, langfuse_prompt = get_prompt_or_fallback("project_brief", _PROJECT_BRIEF_FALLBACK) system_prompt = compile_prompt( raw_template, langfuse_prompt, language=language, today=today, project_id=project_id, ) system_prompt += _relational_memory_injection(context) system_prompt += _proactive_hints_injection(context) system_prompt += _language_instruction(context) if today not in system_prompt: system_prompt += f"\nToday is {today}." tools = _build_read_tools(user_id, trace_id) async for event in _run_single_agent_stream( user_id=user_id, system_prompt=system_prompt, message=f"Generate the project status brief for project {project_id}.", context=context, langfuse_prompt=langfuse_prompt, agent_name="brief-agent", tools=tools, ): yield event