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:
@@ -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")
|
||||
@@ -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<UserProfile>('/api/profile')
|
||||
export const updateProfile = (data: Partial<UserProfile>) =>
|
||||
apiPut<UserProfile>('/api/profile', data)
|
||||
export const consolidateProfile = () =>
|
||||
apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {})
|
||||
export const clearProfileObservations = () => apiDelete('/api/profile/observations')
|
||||
|
||||
@@ -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<HTMLInputElement | null>(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<UserProfile>({
|
||||
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<string[]> { 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 {
|
||||
<div class="sidebar-group">
|
||||
<div class="sidebar-group-label">User</div>
|
||||
<button
|
||||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing', 'voice', 'apikeys']"
|
||||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'briefing', 'voice', 'apikeys']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -1461,6 +1534,128 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
|
||||
<!-- ── Notifications ── -->
|
||||
<!-- ── Profile ── -->
|
||||
<div v-show="activeTab === 'profile'" class="settings-grid">
|
||||
<section class="settings-section full-width">
|
||||
<h2>About You</h2>
|
||||
<p class="section-desc">This information is used by the assistant to personalise responses in chat and briefings.</p>
|
||||
<div class="assistant-grid">
|
||||
<div class="field">
|
||||
<label>Display Name</label>
|
||||
<input v-model="profile.display_name" type="text" class="input" placeholder="e.g. Alex" />
|
||||
<p class="field-hint">How the assistant addresses you.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Job Title</label>
|
||||
<input v-model="profile.job_title" type="text" class="input" placeholder="e.g. Product Manager" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Industry</label>
|
||||
<input v-model="profile.industry" type="text" class="input" placeholder="e.g. Technology" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Expertise Level</label>
|
||||
<select v-model="profile.expertise_level" class="input">
|
||||
<option value="novice">Novice — explain things simply</option>
|
||||
<option value="intermediate">Intermediate — balanced explanations</option>
|
||||
<option value="expert">Expert — assume deep knowledge</option>
|
||||
</select>
|
||||
<p class="field-hint">Calibrates how the assistant explains concepts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Response Preferences</h2>
|
||||
<div class="assistant-grid">
|
||||
<div class="field">
|
||||
<label>Response Style</label>
|
||||
<select v-model="profile.response_style" class="input">
|
||||
<option value="concise">Concise — short and direct</option>
|
||||
<option value="balanced">Balanced — default</option>
|
||||
<option value="detailed">Detailed — thorough explanations</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Tone</label>
|
||||
<select v-model="profile.tone" class="input">
|
||||
<option value="casual">Casual — friendly and relaxed</option>
|
||||
<option value="professional">Professional — formal and precise</option>
|
||||
<option value="technical">Technical — jargon-friendly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Interests</h2>
|
||||
<p class="section-desc">Topics you care about — used to personalise news and briefing context.</p>
|
||||
<TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Work Schedule</h2>
|
||||
<p class="section-desc">Helps the briefing understand when you're working and what's relevant each morning.</p>
|
||||
<div class="field">
|
||||
<label>Work Days</label>
|
||||
<div class="day-picker">
|
||||
<button
|
||||
v-for="day in WORK_DAYS"
|
||||
:key="day"
|
||||
class="day-btn"
|
||||
:class="{ active: (profile.work_schedule.days ?? []).includes(day) }"
|
||||
@click="toggleProfileWorkDay(day)"
|
||||
type="button"
|
||||
>{{ day }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assistant-grid" style="margin-top:0.75rem">
|
||||
<div class="field">
|
||||
<label>Start Time</label>
|
||||
<input v-model="profile.work_schedule.start" type="time" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>End Time</label>
|
||||
<input v-model="profile.work_schedule.end" type="time" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>What the Assistant Has Learned</h2>
|
||||
<p class="section-desc">
|
||||
The assistant observes patterns from your briefing conversations and builds a summary over time.
|
||||
<span v-if="profile.observations_count > 0"> {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored.</span>
|
||||
</p>
|
||||
<div v-if="profile.learned_summary" class="learned-summary">{{ profile.learned_summary }}</div>
|
||||
<div v-else class="learned-empty">No learned summary yet. Observations accumulate from daily briefing conversations.</div>
|
||||
<div class="actions" style="gap:0.5rem;flex-wrap:wrap">
|
||||
<button class="btn-secondary" @click="runConsolidate" :disabled="consolidating || profile.observations_count === 0">
|
||||
{{ consolidating ? 'Consolidating…' : 'Consolidate Now' }}
|
||||
</button>
|
||||
<button class="btn-danger-outline" @click="clearObservations" :disabled="clearingObs">
|
||||
{{ clearingObs ? 'Clearing…' : 'Reset Learned Data' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'notifications'" class="settings-grid">
|
||||
|
||||
<section class="settings-section">
|
||||
@@ -3850,6 +4045,66 @@ FABLE_API_KEY=<your-api-key></pre>
|
||||
}
|
||||
.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; }
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user