diff --git a/alembic/versions/0034_add_user_profiles.py b/alembic/versions/0034_add_user_profiles.py new file mode 100644 index 0000000..8de3835 --- /dev/null +++ b/alembic/versions/0034_add_user_profiles.py @@ -0,0 +1,53 @@ +"""Add user_profiles table for structured per-user preferences.""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import ARRAY, JSONB + +revision = "0034" +down_revision = "0033" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "user_profiles", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ), + sa.Column("display_name", sa.Text(), nullable=True), + sa.Column("job_title", sa.Text(), nullable=True), + sa.Column("industry", sa.Text(), nullable=True), + sa.Column("expertise_level", sa.Text(), nullable=True), + sa.Column("response_style", sa.Text(), nullable=True), + sa.Column("tone", sa.Text(), nullable=True), + sa.Column("interests", ARRAY(sa.Text()), nullable=True), + sa.Column("work_schedule", JSONB(), nullable=True), + sa.Column("learned_summary", sa.Text(), nullable=True), + sa.Column("observations_raw", JSONB(), nullable=True), + sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_user_profiles_user_id", "user_profiles", ["user_id"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_user_profiles_user_id", table_name="user_profiles") + op.drop_table("user_profiles") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9e1b2e7..42daf6f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -667,3 +667,26 @@ export async function synthesiseSpeech( } return res.blob() } + +// ── User Profile ───────────────────────────────────────────────────────────── + +export interface UserProfile { + display_name: string + job_title: string + industry: string + expertise_level: 'novice' | 'intermediate' | 'expert' + response_style: 'concise' | 'balanced' | 'detailed' + tone: 'casual' | 'professional' | 'technical' + interests: string[] + work_schedule: { days?: string[]; start?: string; end?: string } + learned_summary: string + observations_count: number + observations_updated_at: string | null +} + +export const getProfile = () => apiGet('/api/profile') +export const updateProfile = (data: Partial) => + apiPut('/api/profile', data) +export const consolidateProfile = () => + apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {}) +export const clearProfileObservations = () => apiDelete('/api/profile/observations') diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 68059bc..018166b 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue"; import { useSettingsStore } from "@/stores/settings"; import { useAuthStore } from "@/stores/auth"; import { useToastStore } from "@/stores/toast"; -import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed, type VoiceStatusResult, type VoiceEntry } from "@/api/client"; +import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed, type VoiceStatusResult, type VoiceEntry, type UserProfile } from "@/api/client"; import { usePushStore } from "@/stores/push"; import type { User } from "@/types/auth"; import PaginationBar from "@/components/PaginationBar.vue"; @@ -40,7 +40,7 @@ const appVersion = ref('dev'); const restoreFileInput = ref(null); // Migrate stored "admin" → "config"; unknown tabs fall back to "general" -const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "voice", "apikeys", "config", "users", "logs", "groups"]); +const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "briefing", "voice", "apikeys", "config", "users", "logs", "groups"]); const _stored = localStorage.getItem("settings_tab") ?? "general"; const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general"); @@ -546,6 +546,76 @@ async function saveVoiceSettings() { } } +// ── Profile ────────────────────────────────────────────────────────────────── +const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] +const profile = ref({ + display_name: '', job_title: '', industry: '', + expertise_level: 'intermediate', response_style: 'balanced', tone: 'casual', + interests: [], work_schedule: {}, learned_summary: '', + observations_count: 0, observations_updated_at: null, +}) +const profileSaving = ref(false) +const profileSaved = ref(false) +const consolidating = ref(false) +const clearingObs = ref(false) + +async function loadProfile() { + try { profile.value = await getProfile() } catch { /* non-critical */ } +} + +async function saveProfile() { + profileSaving.value = true + profileSaved.value = false + try { + profile.value = await updateProfile({ + display_name: profile.value.display_name, + job_title: profile.value.job_title, + industry: profile.value.industry, + expertise_level: profile.value.expertise_level, + response_style: profile.value.response_style, + tone: profile.value.tone, + interests: profile.value.interests, + work_schedule: profile.value.work_schedule, + }) + profileSaved.value = true + setTimeout(() => { profileSaved.value = false }, 2000) + } catch { toastStore.show('Failed to save profile', 'error') } + finally { profileSaving.value = false } +} + +function toggleProfileWorkDay(day: string) { + const days = [...(profile.value.work_schedule.days ?? [])] + const idx = days.indexOf(day) + if (idx >= 0) days.splice(idx, 1) + else days.push(day) + profile.value.work_schedule = { ...profile.value.work_schedule, days } +} + +async function runConsolidate() { + consolidating.value = true + try { + const result = await consolidateProfile() + profile.value.learned_summary = result.learned_summary + toastStore.show('Profile updated from observations') + } catch { toastStore.show('Consolidation failed', 'error') } + finally { consolidating.value = false } +} + +function emptyTagsFetch(): Promise { return Promise.resolve([]) } + +async function clearObservations() { + if (!confirm('Clear all learned observations and the generated summary? This cannot be undone.')) return + clearingObs.value = true + try { + await clearProfileObservations() + profile.value.learned_summary = '' + profile.value.observations_count = 0 + profile.value.observations_updated_at = null + toastStore.show('Learned data cleared') + } catch { toastStore.show('Failed to clear observations', 'error') } + finally { clearingObs.value = false } +} + onMounted(async () => { try { const v = await apiGet<{ version: string }>('/api/version') @@ -579,6 +649,9 @@ onMounted(async () => { notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false"; } + // Load user profile + await loadProfile(); + // Load voice settings if (allSettings.voice_tts_voice) voiceTtsVoice.value = allSettings.voice_tts_voice; if (allSettings.voice_tts_speed) voiceTtsSpeed.value = parseFloat(allSettings.voice_tts_speed); @@ -1240,7 +1313,7 @@ function formatUserDate(iso: string): string { + + +
+

Response Preferences

+
+
+ + +
+
+ + +
+
+
+ + Saved! +
+
+ +
+

Interests

+

Topics you care about — used to personalise news and briefing context.

+ +
+ + Saved! +
+
+ +
+

Work Schedule

+

Helps the briefing understand when you're working and what's relevant each morning.

+
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+
+ + Saved! +
+
+ +
+

What the Assistant Has Learned

+

+ The assistant observes patterns from your briefing conversations and builds a summary over time. + {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored. +

+
{{ profile.learned_summary }}
+
No learned summary yet. Observations accumulate from daily briefing conversations.
+
+ + +
+
+ +
@@ -3850,6 +4045,66 @@ FABLE_API_KEY=<your-api-key> } .voice-admin-spinner span:nth-child(2) { animation-delay: 0.2s; } .voice-admin-spinner span:nth-child(3) { animation-delay: 0.4s; } + +/* ── Profile tab ─────────────────────────────────────────────────────────── */ +.day-picker { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + margin-top: 0.35rem; +} +.day-btn { + padding: 0.3rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-card); + color: var(--color-text-muted); + font-size: 0.82rem; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} +.day-btn:hover { border-color: var(--color-primary); color: var(--color-primary); } +.day-btn.active { + background: color-mix(in srgb, var(--color-primary) 15%, transparent); + border-color: var(--color-primary); + color: var(--color-primary); + font-weight: 600; +} +.learned-summary { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-left: 3px solid var(--color-primary); + border-radius: 8px; + padding: 0.85rem 1rem; + font-size: 0.9rem; + line-height: 1.55; + color: var(--color-text); + white-space: pre-wrap; + margin-bottom: 0.75rem; +} +.learned-empty { + font-size: 0.85rem; + color: var(--color-text-muted); + font-style: italic; + margin-bottom: 0.75rem; +} +.btn-danger-outline { + padding: 0.45rem 1rem; + background: none; + border: 1px solid var(--color-danger, #e74c3c); + border-radius: var(--radius-sm); + color: var(--color-danger, #e74c3c); + font-size: 0.9rem; + cursor: pointer; + font-family: inherit; + transition: background 0.15s, color 0.15s; +} +.btn-danger-outline:hover:not(:disabled) { + background: var(--color-danger, #e74c3c); + color: #fff; +} +.btn-danger-outline:disabled { opacity: 0.5; cursor: default; } @keyframes va-dot-bounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 5ee4acf..6566325 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -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(): diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py index 4e406c1..0db7d5a 100644 --- a/src/fabledassistant/models/__init__.py +++ b/src/fabledassistant/models/__init__.py @@ -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 diff --git a/src/fabledassistant/models/user_profile.py b/src/fabledassistant/models/user_profile.py new file mode 100644 index 0000000..0e13e61 --- /dev/null +++ b/src/fabledassistant/models/user_profile.py @@ -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 + ), + } diff --git a/src/fabledassistant/routes/profile.py b/src/fabledassistant/routes/profile.py new file mode 100644 index 0000000..4359599 --- /dev/null +++ b/src/fabledassistant/routes/profile.py @@ -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"}) diff --git a/src/fabledassistant/services/briefing_pipeline.py b/src/fabledassistant/services/briefing_pipeline.py index b0cb905..8b51468 100644 --- a/src/fabledassistant/services/briefing_pipeline.py +++ b/src/fabledassistant/services/briefing_pipeline.py @@ -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, ) diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index 2a77eac..ebb404a 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -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 diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index f24fc55..254e60a 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -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}" ] diff --git a/src/fabledassistant/services/user_profile.py b/src/fabledassistant/services/user_profile.py new file mode 100644 index 0000000..14d65f8 --- /dev/null +++ b/src/fabledassistant/services/user_profile.py @@ -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)