feat: structured user profile with LLM-learned preferences

Replaces the freeform briefing-profile note with a DB-backed user_profiles
table. Users can edit job/industry/expertise/response preferences/interests/
work schedule via a new Settings → Profile tab. The LLM appends nightly
observations; at 14+ entries they are auto-consolidated into a learned_summary.
Profile context is injected into both briefing and chat system prompts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 14:17:30 -04:00
parent 9f3b9e45c6
commit dba41879ed
11 changed files with 667 additions and 9 deletions
+2
View File
@@ -32,6 +32,7 @@ from fabledassistant.routes.api_keys import api_keys_bp
from fabledassistant.routes.events import events_bp
from fabledassistant.routes.search import search_bp
from fabledassistant.routes.voice import voice_bp
from fabledassistant.routes.profile import profile_bp
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
@@ -94,6 +95,7 @@ def create_app() -> Quart:
app.register_blueprint(events_bp)
app.register_blueprint(search_bp)
app.register_blueprint(voice_bp)
app.register_blueprint(profile_bp)
@app.before_request
async def before_request():
+1
View File
@@ -41,3 +41,4 @@ from fabledassistant.models.notification import Notification # noqa: E402, F401
from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401
from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401
@@ -0,0 +1,55 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class UserProfile(Base, TimestampMixin):
__tablename__ = "user_profiles"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True
)
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
job_title: Mapped[str | None] = mapped_column(Text, nullable=True)
industry: Mapped[str | None] = mapped_column(Text, nullable=True)
# novice / intermediate / expert — calibrates explanation depth
expertise_level: Mapped[str | None] = mapped_column(Text, nullable=True)
# concise / balanced / detailed
response_style: Mapped[str | None] = mapped_column(Text, nullable=True)
# casual / professional / technical
tone: Mapped[str | None] = mapped_column(Text, nullable=True)
interests: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
# {days: ["Mon","Tue",...], start: "09:00", end: "17:00"}
work_schedule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# LLM-consolidated summary of learned preferences
learned_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
# [{date: "YYYY-MM-DD", bullets: "..."}, ...]
observations_raw: Mapped[list | None] = mapped_column(JSONB, nullable=True)
observations_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
def to_dict(self) -> dict:
return {
"display_name": self.display_name or "",
"job_title": self.job_title or "",
"industry": self.industry or "",
"expertise_level": self.expertise_level or "intermediate",
"response_style": self.response_style or "balanced",
"tone": self.tone or "casual",
"interests": self.interests or [],
"work_schedule": self.work_schedule or {},
"learned_summary": self.learned_summary or "",
"observations_count": len(self.observations_raw or []),
"observations_updated_at": (
self.observations_updated_at.isoformat()
if self.observations_updated_at
else None
),
}
+61
View File
@@ -0,0 +1,61 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.services.user_profile import (
VALID_EXPERTISE,
VALID_STYLES,
VALID_TONES,
clear_learned_data,
consolidate_observations,
get_profile,
update_profile,
)
profile_bp = Blueprint("profile", __name__, url_prefix="/api/profile")
@profile_bp.route("", methods=["GET"])
@login_required
async def get_profile_route():
uid = get_current_user_id()
profile = await get_profile(uid)
return jsonify(profile.to_dict())
@profile_bp.route("", methods=["PUT"])
@login_required
async def update_profile_route():
uid = get_current_user_id()
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if "expertise_level" in data and data["expertise_level"] not in VALID_EXPERTISE:
return jsonify({"error": f"expertise_level must be one of {sorted(VALID_EXPERTISE)}"}), 400
if "response_style" in data and data["response_style"] not in VALID_STYLES:
return jsonify({"error": f"response_style must be one of {sorted(VALID_STYLES)}"}), 400
if "tone" in data and data["tone"] not in VALID_TONES:
return jsonify({"error": f"tone must be one of {sorted(VALID_TONES)}"}), 400
if "interests" in data and not isinstance(data["interests"], list):
return jsonify({"error": "interests must be an array"}), 400
if "work_schedule" in data and not isinstance(data["work_schedule"], dict):
return jsonify({"error": "work_schedule must be an object"}), 400
profile = await update_profile(uid, data)
return jsonify(profile.to_dict())
@profile_bp.route("/consolidate", methods=["POST"])
@login_required
async def trigger_consolidate():
uid = get_current_user_id()
summary = await consolidate_observations(uid)
return jsonify({"status": "ok", "learned_summary": summary})
@profile_bp.route("/observations", methods=["DELETE"])
@login_required
async def clear_observations():
uid = get_current_user_id()
await clear_learned_data(uid)
return jsonify({"status": "ok"})
@@ -357,7 +357,7 @@ async def run_compilation(
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
from fabledassistant.services.briefing_profile import get_profile_body
from fabledassistant.services.user_profile import build_profile_context
from fabledassistant.services.briefing_preferences import (
load_topic_preferences,
load_topic_reaction_scores,
@@ -365,8 +365,8 @@ async def run_compilation(
)
from fabledassistant.services.weather import parse_weather_card_data, get_cached_weather_rows
profile_body, temp_unit = await asyncio.gather(
get_profile_body(user_id),
profile_context, temp_unit = await asyncio.gather(
build_profile_context(user_id),
_get_temp_unit(user_id),
)
@@ -424,7 +424,7 @@ async def run_compilation(
}
briefing_text = await _llm_synthesise(
_unified_system_prompt(profile_body),
_unified_system_prompt(profile_context),
_unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
model,
)
@@ -208,7 +208,7 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
Read yesterday's briefing conversation, extract preference observations,
and append them to the briefing profile note.
"""
from fabledassistant.services.briefing_profile import append_observations
from fabledassistant.services.user_profile import append_observations
from fabledassistant.services.briefing_pipeline import _llm_synthesise
from fabledassistant.models.conversation import Conversation, Message
+6 -1
View File
@@ -512,11 +512,16 @@ async def build_context(
tool_guidance = "\n".join(tool_lines)
tz_line = f" The user's timezone is {user_timezone}." if user_timezone else ""
from fabledassistant.services.user_profile import build_profile_context
profile_context = await build_profile_context(user_id)
profile_section = f"\n\n{profile_context}" if profile_context else ""
system_parts = [
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
"Help users with their notes, tasks, and general questions. "
"When note context is provided, use it to give relevant answers. "
f"Today's date is {today}.{tz_line}\n\n"
f"Today's date is {today}.{tz_line}{profile_section}\n\n"
f"{tool_guidance}"
]
@@ -0,0 +1,203 @@
"""User profile service — structured per-user preferences for LLM context."""
import asyncio
import logging
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.user_profile import UserProfile
logger = logging.getLogger(__name__)
VALID_EXPERTISE = {"novice", "intermediate", "expert"}
VALID_STYLES = {"concise", "balanced", "detailed"}
VALID_TONES = {"casual", "professional", "technical"}
# Trigger consolidation when raw observations reach this count
_CONSOLIDATION_THRESHOLD = 14
async def get_profile(user_id: int) -> UserProfile:
"""Get or create the profile row for a user."""
async with async_session() as session:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = result.scalar_one_or_none()
if profile is None:
profile = UserProfile(user_id=user_id)
session.add(profile)
await session.commit()
await session.refresh(profile)
return profile
async def update_profile(user_id: int, data: dict) -> UserProfile:
"""Upsert structured profile fields from a validated dict."""
allowed = {
"display_name", "job_title", "industry", "expertise_level",
"response_style", "tone", "interests", "work_schedule",
}
async with async_session() as session:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = result.scalar_one_or_none()
if profile is None:
profile = UserProfile(user_id=user_id)
session.add(profile)
for key, value in data.items():
if key in allowed:
setattr(profile, key, value)
await session.commit()
await session.refresh(profile)
return profile
async def append_observations(user_id: int, bullets: str) -> None:
"""
Append a new dated observation entry from the day's briefing closeout.
Automatically triggers consolidation when the raw list grows large.
"""
if not bullets.strip():
return
async with async_session() as session:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = result.scalar_one_or_none()
if profile is None:
profile = UserProfile(user_id=user_id)
session.add(profile)
existing: list = list(profile.observations_raw or [])
existing.append({
"date": date.today().isoformat(),
"bullets": bullets.strip(),
})
# Keep at most 60 raw entries as a rolling window
profile.observations_raw = existing[-60:]
profile.observations_updated_at = datetime.now(timezone.utc)
await session.commit()
raw_count = len(profile.observations_raw or [])
logger.info("Appended observations for user %d (%d raw entries)", user_id, raw_count)
if raw_count >= _CONSOLIDATION_THRESHOLD:
asyncio.create_task(_consolidate_observations(user_id))
async def consolidate_observations(user_id: int) -> str:
"""Public entry point to manually trigger observation consolidation."""
return await _consolidate_observations(user_id)
async def clear_learned_data(user_id: int) -> None:
"""Reset all learned observations and summary for a user."""
async with async_session() as session:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = result.scalar_one_or_none()
if profile:
profile.learned_summary = None
profile.observations_raw = []
profile.observations_updated_at = None
await session.commit()
async def _consolidate_observations(user_id: int) -> str:
"""
LLM pass: synthesise all raw observation bullets into an updated
learned_summary paragraph. Prunes raw entries older than 30 days afterwards.
"""
from fabledassistant.config import Config
from fabledassistant.services.briefing_pipeline import _llm_synthesise
from fabledassistant.services.settings import get_setting
async with async_session() as session:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = result.scalar_one_or_none()
if not profile or not profile.observations_raw:
return ""
observations = list(profile.observations_raw)
existing_summary = profile.learned_summary or ""
obs_text = "\n\n".join(
f"[{entry['date']}]\n{entry['bullets']}"
for entry in observations
)
system = (
"You are synthesising preference observations into a concise user profile summary. "
"Consolidate the observations into 3-6 factual sentences describing the user's patterns, "
"preferences, and habits. Be specific and useful for a personal assistant. "
"Merge with any existing summary, removing duplicates and outdated information. "
"Output only the consolidated summary paragraph — no preamble, no bullet points."
)
user_prompt = ""
if existing_summary:
user_prompt += f"Existing summary:\n{existing_summary}\n\n"
user_prompt += f"New observations:\n{obs_text}"
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
new_summary = await _llm_synthesise(system, user_prompt, model)
if new_summary:
cutoff = (date.today() - timedelta(days=30)).isoformat()
async with async_session() as session:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = result.scalar_one_or_none()
if profile:
profile.learned_summary = new_summary
profile.observations_raw = [
o for o in (profile.observations_raw or [])
if o.get("date", "") >= cutoff
]
await session.commit()
logger.info("Consolidated observations for user %d", user_id)
return new_summary
async def build_profile_context(user_id: int) -> str:
"""
Build a formatted context string from the user's structured profile
for injection into LLM system prompts (briefing and chat).
Returns an empty string if no meaningful data is set.
"""
profile = await get_profile(user_id)
parts: list[str] = []
if profile.display_name:
parts.append(f"User's name: {profile.display_name}")
if profile.job_title or profile.industry:
job = " in ".join(filter(None, [profile.job_title, profile.industry]))
parts.append(f"Occupation: {job}")
if profile.expertise_level and profile.expertise_level != "intermediate":
parts.append(
f"Expertise level: {profile.expertise_level} — calibrate explanation depth accordingly"
)
if profile.response_style or profile.tone:
style = profile.response_style or "balanced"
tone = profile.tone or "casual"
parts.append(f"Preferred response style: {style}, tone: {tone}")
if profile.interests:
parts.append(f"Interests: {', '.join(profile.interests)}")
if profile.work_schedule:
sched = profile.work_schedule
days = ", ".join(sched.get("days") or []) or "weekdays"
start = sched.get("start", "9:00")
end = sched.get("end", "17:00")
parts.append(f"Work schedule: {days}, {start}{end}")
if profile.learned_summary:
parts.append(f"What the assistant has learned about this user: {profile.learned_summary}")
return "\n".join(parts)