Vital / llm /weekly_report.py
eddyejembi's picture
Upload folder using huggingface_hub
4e88df3 verified
Raw
History Blame Contribute Delete
9.08 kB
"""Weekly report generation — LLM prompt, schema, validation, and persistence."""
import json
import logging
from datetime import date, datetime, timedelta, timezone
from db import queries
from vital_types.db import ProfileInput, WeeklyReport
from llm.client import get_llm_client
logger = logging.getLogger(__name__)
WEEKLY_REPORT_JSON_SCHEMA: dict[str, object] = {
"type": "object",
"properties": {
"report_text": {
"type": "string",
"minLength": 50,
"maxLength": 4000,
"description": (
"A warm weekly coaching narrative summarizing wins, gaps, and "
"one focus for next week. Plain text, 3-6 short paragraphs."
),
},
"highlights": {
"type": "array",
"minItems": 1,
"maxItems": 5,
"items": {"type": "string", "minLength": 5, "maxLength": 200},
"description": "Bullet-worthy wins from the week.",
},
"focus_next_week": {
"type": "string",
"minLength": 10,
"maxLength": 300,
"description": "One practical focus area for the coming week.",
},
},
"required": ["report_text", "highlights", "focus_next_week"],
"additionalProperties": False,
}
WEEKLY_REPORT_SYSTEM = """
You are Vitál's weekly wellness coach. You write an end-of-week report for ONE user based on
their profile and the structured week data provided. You are NOT having a conversation — you
output a single JSON object the app saves to the Report tab.
WHAT YOU RECEIVE:
- User profile (goal, conditions, medications, dietary context).
- Aggregated stats for the week (medication adherence, exercise count, food logs, check-ins).
- Day-by-day check-in logs, food logs, and exercise logs when available.
RULES:
- Be encouraging, specific, and honest — cite real numbers from the data.
- Mention medication adherence, movement, hydration/check-ins, and meals logged when relevant.
- If data is sparse, say so kindly and suggest one concrete habit to build.
- Never diagnose. For serious symptoms mentioned in logs, advise seeking medical care.
- report_text: 3-6 short paragraphs, readable on the Report tab.
- highlights: 1-5 short win bullets drawn from the data.
- focus_next_week: one actionable priority (not a full meal plan — daily plans handle that).
Return ONLY valid JSON matching the schema. No markdown fences, no commentary.
"""
class WeeklyReportValidationError(ValueError):
"""Raised when weekly report JSON fails validation."""
def _week_label(week_start: date) -> str:
"""Format a week range label for prompts."""
week_end = week_start + timedelta(days=6)
return f"{week_start.isoformat()} to {week_end.isoformat()}"
def build_weekly_report_context(
profile: ProfileInput,
week_start: date,
) -> str:
"""Assemble week data for the weekly report LLM prompt."""
week_end = week_start + timedelta(days=6)
summary = queries.get_weekly_summary_for_week(week_start)
check_in_lines: list[str] = []
food_lines: list[str] = []
exercise_lines: list[str] = []
current = week_start
while current <= week_end:
day_label = current.strftime("%A %Y-%m-%d")
for entry in queries.get_daily_logs_for_date(current):
check_in_lines.append(f" {day_label}: {entry.field_id}={entry.value}")
for entry in queries.get_food_logs_for_date(current):
food_lines.append(
f" {day_label}: {entry.meal_type} — {entry.food_description}"
)
for entry in queries.get_exercise_logs_for_date(current):
if entry.completed:
exercise_lines.append(
f" {day_label}: {entry.exercise_type} {entry.duration_minutes} min"
)
current += timedelta(days=1)
return (
f"Week: {_week_label(week_start)}\n"
f"User: {profile.name} | Goal: {profile.goal}\n"
f"Conditions: {', '.join(profile.conditions) or 'none'}\n"
f"Summary stats: {json.dumps(summary)}\n"
f"Check-in logs:\n"
+ ("\n".join(check_in_lines) if check_in_lines else " (none)")
+ "\nFood logs:\n"
+ ("\n".join(food_lines) if food_lines else " (none)")
+ "\nExercise logs:\n"
+ ("\n".join(exercise_lines) if exercise_lines else " (none)")
)
def build_weekly_report_user_prompt(
profile: ProfileInput,
week_start: date,
) -> str:
"""Build the user prompt for weekly report generation."""
context = build_weekly_report_context(profile, week_start)
return (
f"Write the weekly wellness report for {profile.name}.\n\n"
f"{context}\n\n"
"Return JSON with report_text, highlights, and focus_next_week."
)
def validate_weekly_report_response(payload: dict[str, object]) -> str:
"""Validate LLM weekly report JSON and return the narrative text."""
report_text = payload.get("report_text")
highlights = payload.get("highlights")
focus = payload.get("focus_next_week")
if not isinstance(report_text, str) or not report_text.strip():
raise WeeklyReportValidationError("report_text is required.")
if not isinstance(highlights, list) or len(highlights) < 1:
raise WeeklyReportValidationError("highlights must be a non-empty array.")
if not isinstance(focus, str) or not focus.strip():
raise WeeklyReportValidationError("focus_next_week is required.")
trimmed = report_text.strip()
highlight_block = "\n".join(
f"- {item.strip()}"
for item in highlights
if isinstance(item, str) and item.strip()
)
if highlight_block:
trimmed = f"{trimmed}\n\n**Highlights**\n{highlight_block}"
trimmed = f"{trimmed}\n\n**Focus next week:** {focus.strip()}"
return trimmed
def _fallback_weekly_report_text(
profile: ProfileInput,
week_start: date,
) -> str:
"""Build a deterministic report when the LLM is unavailable."""
summary = queries.get_weekly_summary_for_week(week_start)
return (
f"Week of {week_start.isoformat()}: {profile.name}, here's your week in numbers. "
f"You took {summary.get('medications_taken')}/{summary.get('medications_total')} "
f"scheduled medications ({summary.get('medication_adherence_percent')}% adherence), "
f"completed {summary.get('exercises_completed')} exercises, and logged "
f"{summary.get('food_entries')} meals. "
f"Keep building steady habits — Vitál will keep nudging you daily."
)
def generate_weekly_report(
week_start: date,
force: bool = False,
fallback_only: bool = False,
) -> WeeklyReport | None:
"""Generate and persist the weekly report for a Monday week_start date."""
profile = queries.get_profile()
if profile is None or not queries.check_onboarding_status():
logger.info("[weekly_report] Skipped — user not onboarded.")
return None
existing = queries.get_weekly_report(week_start)
if existing is not None and not force:
logger.info(
"[weekly_report] Report already exists for %s — loading.",
week_start.isoformat(),
)
return existing
if existing is not None and force:
queries.delete_weekly_report(week_start)
logger.info(
"[weekly_report] Cleared existing report for %s (force regenerate).",
week_start.isoformat(),
)
summary = queries.get_weekly_summary_for_week(week_start)
report_text = ""
if fallback_only:
report_text = _fallback_weekly_report_text(profile, week_start)
logger.info(
"[weekly_report] Template report for %s (fallback_only).",
week_start.isoformat(),
)
else:
try:
client = get_llm_client()
payload = client.generate_onboarding_json(
build_weekly_report_user_prompt(profile, week_start),
system_prompt=WEEKLY_REPORT_SYSTEM,
json_schema=WEEKLY_REPORT_JSON_SCHEMA,
)
report_text = validate_weekly_report_response(payload)
logger.info(
"[weekly_report] LLM report for %s (%s chars).",
week_start.isoformat(),
len(report_text),
)
except Exception as error:
logger.warning(
"[weekly_report] LLM failed for %s: %s — using template fallback.",
week_start.isoformat(),
error,
)
report_text = _fallback_weekly_report_text(profile, week_start)
report = WeeklyReport(
week_start=week_start,
report_text=report_text,
water_goals_hit=0,
medication_adherence=float(summary.get("medication_adherence_percent", 0.0)),
exercises_completed=int(summary.get("exercises_completed", 0)),
generated_at=datetime.now(timezone.utc),
)
queries.insert_weekly_report(report)
return report