9eba6ac107
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>
214 lines
8.0 KiB
Python
214 lines
8.0 KiB
Python
"""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)
|