Files
FabledScribe/src/fabledassistant/services/user_profile.py
T
bvandeusen 9eba6ac107 refactor(briefing)!: remove legacy one-shot synthesis, agentic-only
Deletes ~760 lines of legacy briefing code: format_task, compute_task_hash,
upsert_task_snapshots, _gather_internal, _gather_weekly_review,
_llm_synthesise, and the unified prompt helpers. run_compilation and
run_slot_injection are now agentic-tool-use-loop only.

briefing_scheduler and user_profile migrated from the deleted helper to
services.llm.generate_completion (retry + keep_alive baked in).

routes/briefing.manual_trigger now persists agentic tool-call receipts
via _persist_agentic_messages (previously silently dropped them) and
adds POST /api/briefing/reset-today to wipe today's briefing messages.

BREAKING: briefing_mode setting no longer honored; no legacy fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:42:42 -04:00

214 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.llm import generate_completion
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)
try:
new_summary = (await generate_completion(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
model,
)).strip()
except Exception:
logger.warning("Observation consolidation failed for user %d", user_id, exc_info=True)
new_summary = ""
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)