fix(6229): remove the Profile settings page — nothing read it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 43s

The Profile tab said an assistant used it to personalise chat and a daily
journal. Neither exists, and nothing read the profile: the page saved to
user_profiles and the backup carried it. Removed the tab, the client calls,
/api/profile, the service and the model, and migration 0123 drops the table
(downgrade recreates it empty).

Backup format v27 stops writing user_profiles; an older archive carrying the
section still restores, and the file-restore integration test now proves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:14:40 -04:00
co-authored by Claude Opus 5.5
parent 067aa58fbc
commit 54206afa76
13 changed files with 83 additions and 453 deletions
-2
View File
@@ -23,7 +23,6 @@ from scribe.routes.in_app_notifications import notifications_bp
from scribe.routes.users import users_bp
from scribe.routes.api_keys import api_keys_bp
from scribe.routes.search import search_bp
from scribe.routes.profile import profile_bp
from scribe.routes.knowledge import knowledge_bp
from scribe.routes.rulebooks import rulebooks_bp
from scribe.routes.plugin import plugin_bp
@@ -93,7 +92,6 @@ def create_app() -> Quart:
app.register_blueprint(users_bp)
app.register_blueprint(api_keys_bp)
app.register_blueprint(search_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(knowledge_bp)
app.register_blueprint(lessons_bp)
app.register_blueprint(rulebooks_bp)
-1
View File
@@ -74,7 +74,6 @@ from scribe.models.group import Group, GroupMembership # noqa: E402, F401
from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
from scribe.models.notification import Notification # noqa: E402, F401
from scribe.models.api_key import ApiKey # noqa: E402, F401
from scribe.models.user_profile import UserProfile # noqa: E402, F401
# Imported before rulebook: rule_systems foreign-keys canonical_systems.
from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
from scribe.models.rulebook import ( # noqa: E402, F401
-39
View File
@@ -1,39 +0,0 @@
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.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)
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 {},
}
-43
View File
@@ -1,43 +0,0 @@
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services.user_profile import (
VALID_EXPERTISE,
VALID_STYLES,
VALID_TONES,
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())
+5 -65
View File
@@ -38,7 +38,6 @@ from scribe.models.setting import Setting
from scribe.models.system import RecordSystem, System
from scribe.models.task_log import TaskLog
from scribe.models.user import User
from scribe.models.user_profile import UserProfile
logger = logging.getLogger(__name__)
@@ -109,9 +108,10 @@ logger = logging.getLogger(__name__)
# ledger and the decision log. Lose the ledger or the log and every
# assessment is owed again, with nothing to keep it consistent with the
# last.
# v26 user_profiles: what each user wrote on their Profile page. Every field
# is typed by its user, and none is derived or a credential.
BACKUP_VERSION = 26
# v26 user_profiles: what each user wrote on their Profile page.
# v27 drops user_profiles with the Profile page. Older archives carrying the
# section still restore — the key is simply not read.
BACKUP_VERSION = 27
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -162,8 +162,6 @@ _BACKED_UP = [
# memberships.
"platforms", "project_platforms", "family_ideas", "family_idea_platforms",
"family_idea_references", "family_adoptions", "family_decisions",
# What each user wrote on their Profile page.
"user_profiles",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -269,8 +267,6 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"retrieval_tuning_events": {"id"},
# Every other column is the correction itself, or who made it and why.
"moment_mappings": {"id"},
# One row per user, keyed by the user; every other column is what they typed.
"user_profiles": {"id"},
"design_systems": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
"design_tokens": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
"repo_bindings": {"id", "created_at", "updated_at"},
@@ -373,7 +369,6 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"system_usage_events": {"id"},
"retrieval_tuning_events": {"id"},
"moment_mappings": {"id"},
"user_profiles": {"id"},
"design_systems": {
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
},
@@ -550,24 +545,6 @@ def _moment_mapping_rows(rows) -> list[dict]:
]
def _user_profile_rows(rows) -> list[dict]:
"""The Profile page. Not `to_dict()`: that one fills blanks with
the page's defaults and omits `user_id`, and a restore needs the stored
value and the owner."""
return [
{
"user_id": r.user_id, "display_name": r.display_name,
"job_title": r.job_title, "industry": r.industry,
"expertise_level": r.expertise_level,
"response_style": r.response_style, "tone": r.tone,
"interests": r.interests, "work_schedule": r.work_schedule,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rows
]
def _retrieval_tuning_event_rows(rows) -> list[dict]:
"""The record of why a retrieval dial is where it is.
@@ -1064,9 +1041,6 @@ async def export_full_backup() -> dict:
moment_mappings = (await session.execute(
select(MomentMapping).order_by(MomentMapping.id)
)).scalars().all()
user_profiles = (await session.execute(
select(UserProfile).order_by(UserProfile.id)
)).scalars().all()
repo_bindings = (await session.execute(select(RepoBinding).order_by(RepoBinding.id))).scalars().all()
code_shapes = (await session.execute(select(CodeShape).order_by(CodeShape.id))).scalars().all()
code_shape_events = (await session.execute(
@@ -1141,7 +1115,6 @@ async def export_full_backup() -> dict:
retrieval_tuning_events
),
"moment_mappings": _moment_mapping_rows(moment_mappings),
"user_profiles": _user_profile_rows(user_profiles),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
@@ -1327,9 +1300,6 @@ async def export_user_backup(user_id: int) -> dict:
.where(MomentMapping.user_id == user_id)
.order_by(MomentMapping.id)
)).scalars().all()
user_profiles = (await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)).scalars().all()
rule_relations = (await session.execute(
select(RuleRelation).where(
RuleRelation.from_rule_id.in_(_rule_ids),
@@ -1432,7 +1402,6 @@ async def export_user_backup(user_id: int) -> dict:
retrieval_tuning_events
),
"moment_mappings": _moment_mapping_rows(moment_mappings),
"user_profiles": _user_profile_rows(user_profiles),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
@@ -1663,27 +1632,6 @@ def _build_moment_mapping(row: dict, maps: _Maps) -> MomentMapping | None:
)
def _build_user_profile(row: dict, maps: _Maps) -> UserProfile | None:
"""Only the user remaps. A blank field stays blank, never the page's
default: `to_dict()` fills those in on read, from the same None."""
uid = maps.users.get(row.get("user_id") or 0)
if uid is None:
return None
return UserProfile(
user_id=uid,
display_name=row.get("display_name"),
job_title=row.get("job_title"),
industry=row.get("industry"),
expertise_level=row.get("expertise_level"),
response_style=row.get("response_style"),
tone=row.get("tone"),
interests=row.get("interests"),
work_schedule=row.get("work_schedule"),
created_at=_dt(row.get("created_at")),
updated_at=_dt(row.get("updated_at")),
)
def _build_retrieval_tuning_event(row: dict, maps: _Maps) -> RetrievalTuningEvent | None:
"""No id remapping beyond the user: `surface` is a registry NAME, not a
foreign key, which is what lets this history survive a restore into an
@@ -2398,7 +2346,7 @@ async def _restore_v2(data: dict) -> dict:
"rule_systems": 0, "rule_moments": 0, "rule_moment_judgments": 0,
"rule_relations": 0, "rule_versions": 0,
"retrieval_tuning_events": 0, "lesson_rule_links": 0,
"lesson_no_rule": 0, "moment_mappings": 0, "user_profiles": 0,
"lesson_no_rule": 0, "moment_mappings": 0,
"platforms": 0, "project_platforms": 0, "family_ideas": 0,
"family_idea_platforms": 0, "family_idea_references": 0,
"family_adoptions": 0, "family_decisions": 0,
@@ -2520,14 +2468,6 @@ async def _restore_v2(data: dict) -> dict:
session.add(mapping)
stats["moment_mappings"] += 1
# 8d. User profiles (v26) — one per user, so only the user remaps.
for up_data in data.get("user_profiles", []):
profile = _build_user_profile(up_data, maps)
if profile is None:
continue
session.add(profile)
stats["user_profiles"] += 1
# 9. Rulebooks (v3)
for rb_data in data.get("rulebooks", []):
rb = _build_rulebook(rb_data, maps)
-55
View File
@@ -1,55 +0,0 @@
"""User profile service — structured per-user preferences.
The profile is user-managed metadata (name, job, expertise, style, tone,
interests, work schedule), read and written through /api/profile and carried
in backups.
"""
import logging
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.user_profile import UserProfile
logger = logging.getLogger(__name__)
VALID_EXPERTISE = {"novice", "intermediate", "expert"}
VALID_STYLES = {"concise", "balanced", "detailed"}
VALID_TONES = {"casual", "professional", "technical"}
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