diff --git a/apps/scut-senior/api/migrations/0014_byok_cross_device.sql b/apps/scut-senior/api/migrations/0014_byok_cross_device.sql new file mode 100644 index 00000000..dbe3c86c --- /dev/null +++ b/apps/scut-senior/api/migrations/0014_byok_cross_device.sql @@ -0,0 +1,29 @@ +-- BYOK 跨设备:凭据作用域从“登录会话”改为“用户(GitHub 账号)”。 +-- 同一账号在任何设备登录(新 auth_session_id)都能解密同一把 BYOK。 +-- +-- 安全说明:AES-256-GCM 的 AAD 由绑定 user_id+auth_session_id+provider_id +-- 改为绑定 user_id+provider_id(见 credentials._credential_aad),因此旧密文 +-- 无法再解密。故这里不迁移旧密文,用户需在任一设备重新保存一次 API Key。 + +DROP TRIGGER IF EXISTS delete_credentials_when_session_revoked; +DROP INDEX IF EXISTS idx_model_credentials_expiry; +DROP TABLE IF EXISTS model_credentials; + +CREATE TABLE model_credentials ( + user_id TEXT NOT NULL, + provider_id TEXT NOT NULL CHECK ( + provider_id IN ('openrouter', 'deepseek', 'siliconflow', 'zhipu') + ), + ciphertext BLOB NOT NULL CHECK (length(ciphertext) > 16), + nonce BLOB NOT NULL CHECK (length(nonce) = 12), + algorithm TEXT NOT NULL CHECK (algorithm = 'AES-256-GCM'), + key_version INTEGER NOT NULL CHECK (key_version > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + PRIMARY KEY (user_id, provider_id), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_model_credentials_expiry + ON model_credentials (expires_at); diff --git a/apps/scut-senior/api/migrations/0015_user_preferences.sql b/apps/scut-senior/api/migrations/0015_user_preferences.sql new file mode 100644 index 00000000..37aa54d9 --- /dev/null +++ b/apps/scut-senior/api/migrations/0015_user_preferences.sql @@ -0,0 +1,11 @@ +-- 个人中心偏好:随 GitHub 账号(user_id)维护,跨设备同步。 +-- 存 theme_mode / accent_theme / answer_mode / tone 等键值对,值以文本存储。 + +CREATE TABLE IF NOT EXISTS user_preferences ( + user_id TEXT NOT NULL, + preference_key TEXT NOT NULL, + preference_value TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, preference_key), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE +); diff --git a/apps/scut-senior/api/src/scut_senior_api/adapters/openrouter.py b/apps/scut-senior/api/src/scut_senior_api/adapters/openrouter.py index e1d78064..820546af 100644 --- a/apps/scut-senior/api/src/scut_senior_api/adapters/openrouter.py +++ b/apps/scut-senior/api/src/scut_senior_api/adapters/openrouter.py @@ -294,9 +294,9 @@ def _build_structured_request( ), }, ], - # 详细模式 + 公式 + 附录引用很容易超过 2048 token(线上实测被截断); - # 8192 与 BYOK 目录默认值对齐,只影响实际生成量。 - "max_tokens": 8192, + # 推理模型会把一部分输出预算用于 reasoning;16384 为正文和推理 + # 同时留出空间,只影响实际生成量。 + "max_tokens": 16384, "temperature": 0.2, } diff --git a/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py b/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py index 9739ef59..4ed2d75f 100644 --- a/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py +++ b/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py @@ -49,6 +49,10 @@ HISTORY_TTL = timedelta(days=30) +# User-scoped BYOK credentials are independent of any login session, so they +# survive re-login on another device. Give them a fixed lifetime rather than a +# session-bound one; a user re-saves / rotates before it lapses. +BYOK_CREDENTIAL_LIFETIME_DAYS = 365 PRIVATE_DIRECTORY_MODE = 0o700 PRIVATE_FILE_MODE = 0o600 @@ -678,6 +682,44 @@ def delete_account(self, user_id: str) -> dict[str, int]: connection.close() return counts + def get_user_preferences(self, user_id: str) -> dict[str, str]: + normalized_user_id = str(UUID(str(user_id))) + with self._connect() as connection: + rows = connection.execute( + """ + SELECT preference_key, preference_value + FROM user_preferences + WHERE user_id = ? + ORDER BY preference_key + """, + (normalized_user_id,), + ).fetchall() + return {row["preference_key"]: row["preference_value"] for row in rows} + + def set_user_preference(self, user_id: str, key: str, value: str) -> None: + normalized_user_id = str(UUID(str(user_id))) + now = self._now().isoformat() + with self._connect() as connection: + connection.execute( + """ + INSERT INTO user_preferences ( + user_id, preference_key, preference_value, updated_at + ) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, preference_key) DO UPDATE SET + preference_value = excluded.preference_value, + updated_at = excluded.updated_at + """, + (normalized_user_id, key, value, now), + ) + + def delete_user_preference(self, user_id: str, key: str) -> None: + normalized_user_id = str(UUID(str(user_id))) + with self._connect() as connection: + connection.execute( + "DELETE FROM user_preferences WHERE user_id = ? AND preference_key = ?", + (normalized_user_id, key), + ) + def export_account_data(self, user_id: str) -> dict[str, object]: """导出本人数据:历史、贡献与临时材料元数据。 @@ -1157,7 +1199,6 @@ def session_is_active(self, user_id: UUID, auth_session_id: UUID) -> bool: def _stored_model_credential(row: sqlite3.Row) -> StoredModelCredential: return StoredModelCredential( user_id=UUID(row["user_id"]), - auth_session_id=UUID(row["auth_session_id"]), provider_id=row["provider_id"], ciphertext=bytes(row["ciphertext"]), nonce=bytes(row["nonce"]), @@ -1167,56 +1208,36 @@ def _stored_model_credential(row: sqlite3.Row) -> StoredModelCredential: updated_at=datetime.fromisoformat(row["updated_at"]), ) - def list_model_credentials( - self, user_id: UUID, auth_session_id: UUID - ) -> list[StoredModelCredential]: + def list_model_credentials(self, user_id: UUID) -> list[StoredModelCredential]: self.cleanup_auth_records() now = self._now().isoformat() with self._connect() as connection: rows = connection.execute( """ - SELECT c.user_id, c.auth_session_id, c.provider_id, - c.ciphertext, c.nonce, c.algorithm, c.key_version, - c.expires_at, c.updated_at - FROM model_credentials AS c - JOIN auth_sessions AS s - ON s.auth_session_id = c.auth_session_id - AND s.user_id = c.user_id - WHERE c.user_id = ? AND c.auth_session_id = ? - AND c.expires_at > ? - AND s.revoked_at IS NULL AND s.expires_at > ? - ORDER BY c.provider_id + SELECT user_id, provider_id, ciphertext, nonce, algorithm, + key_version, expires_at, updated_at + FROM model_credentials + WHERE user_id = ? AND expires_at > ? + ORDER BY provider_id """, - (str(user_id), str(auth_session_id), now, now), + (str(user_id), now), ).fetchall() return [self._stored_model_credential(row) for row in rows] def get_model_credential( - self, user_id: UUID, auth_session_id: UUID, provider_id: str + self, user_id: UUID, provider_id: str ) -> StoredModelCredential | None: self.cleanup_auth_records() now = self._now().isoformat() with self._connect() as connection: row = connection.execute( """ - SELECT c.user_id, c.auth_session_id, c.provider_id, - c.ciphertext, c.nonce, c.algorithm, c.key_version, - c.expires_at, c.updated_at - FROM model_credentials AS c - JOIN auth_sessions AS s - ON s.auth_session_id = c.auth_session_id - AND s.user_id = c.user_id - WHERE c.user_id = ? AND c.auth_session_id = ? - AND c.provider_id = ? AND c.expires_at > ? - AND s.revoked_at IS NULL AND s.expires_at > ? + SELECT user_id, provider_id, ciphertext, nonce, algorithm, + key_version, expires_at, updated_at + FROM model_credentials + WHERE user_id = ? AND provider_id = ? AND expires_at > ? """, - ( - str(user_id), - str(auth_session_id), - provider_id, - now, - now, - ), + (str(user_id), provider_id, now), ).fetchone() return self._stored_model_credential(row) if row is not None else None @@ -1224,7 +1245,6 @@ def upsert_model_credential( self, *, user_id: UUID, - auth_session_id: UUID, provider_id: str, ciphertext: bytes, nonce: bytes, @@ -1239,32 +1259,19 @@ def upsert_model_credential( raise ValueError("invalid credential key version") now_value = self._now() now = now_value.isoformat() + # Per-user credentials live for a year from the write; they are no + # longer bound to a (7-day) login session, so they survive re-login on + # another device. The active-session check lives in the credential + # manager, not here. + expires_at = (now_value + timedelta(days=BYOK_CREDENTIAL_LIFETIME_DAYS)).isoformat() with self._connect() as connection: - # Serialize the active-session check with credential replacement. - # If replacement wins, a later revoke trigger deletes the row; if - # revoke wins, this check fails and no late ciphertext is written. - connection.execute("BEGIN IMMEDIATE") - session = connection.execute( - """ - SELECT expires_at FROM auth_sessions - WHERE auth_session_id = ? AND user_id = ? - AND revoked_at IS NULL AND expires_at > ? - """, - (str(auth_session_id), str(user_id), now), - ).fetchone() - if session is None: - raise AuthRequired() - expires_at = datetime.fromisoformat(session["expires_at"]) - if expires_at <= now_value: - raise AuthRequired() connection.execute( """ INSERT INTO model_credentials ( - auth_session_id, user_id, provider_id, ciphertext, nonce, - algorithm, key_version, - created_at, updated_at, expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(auth_session_id, provider_id) DO UPDATE SET + user_id, provider_id, ciphertext, nonce, algorithm, + key_version, created_at, updated_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, provider_id) DO UPDATE SET ciphertext = excluded.ciphertext, nonce = excluded.nonce, algorithm = excluded.algorithm, @@ -1273,7 +1280,6 @@ def upsert_model_credential( expires_at = excluded.expires_at """, ( - str(auth_session_id), str(user_id), provider_id, sqlite3.Binary(ciphertext), @@ -1282,43 +1288,32 @@ def upsert_model_credential( key_version, now, now, - expires_at.isoformat(), + expires_at, ), ) row = connection.execute( """ - SELECT user_id, auth_session_id, provider_id, ciphertext, - nonce, algorithm, key_version, expires_at, updated_at + SELECT user_id, provider_id, ciphertext, nonce, algorithm, + key_version, expires_at, updated_at FROM model_credentials - WHERE auth_session_id = ? AND provider_id = ? + WHERE user_id = ? AND provider_id = ? """, - (str(auth_session_id), provider_id), + (str(user_id), provider_id), ).fetchone() if row is None: raise RuntimeError("model credential was not persisted") return self._stored_model_credential(row) def delete_model_credential( - self, user_id: UUID, auth_session_id: UUID, provider_id: str + self, user_id: UUID, provider_id: str ) -> bool: - now = self._now().isoformat() with self._connect() as connection: - active = connection.execute( - """ - SELECT 1 FROM auth_sessions - WHERE auth_session_id = ? AND user_id = ? - AND revoked_at IS NULL AND expires_at > ? - """, - (str(auth_session_id), str(user_id), now), - ).fetchone() - if active is None: - raise AuthRequired() cursor = connection.execute( """ DELETE FROM model_credentials - WHERE auth_session_id = ? AND user_id = ? AND provider_id = ? + WHERE user_id = ? AND provider_id = ? """, - (str(auth_session_id), str(user_id), provider_id), + (str(user_id), provider_id), ) return cursor.rowcount == 1 @@ -1538,7 +1533,7 @@ def get_conversation( FROM workflow_runs WHERE conversation_id = ? AND user_id = ? AND run_status NOT IN ('created', 'running') - ORDER BY created_at DESC, workflow_run_id DESC + ORDER BY created_at ASC, workflow_run_id ASC """, (str(conversation_id), user_id), ).fetchall() diff --git a/apps/scut-senior/api/src/scut_senior_api/byok_catalog.py b/apps/scut-senior/api/src/scut_senior_api/byok_catalog.py index 0fd5259e..cca3a769 100644 --- a/apps/scut-senior/api/src/scut_senior_api/byok_catalog.py +++ b/apps/scut-senior/api/src/scut_senior_api/byok_catalog.py @@ -97,7 +97,7 @@ def as_public_dict(self) -> dict[str, object]: # DeepSeek is a reasoning model: its thinking consumes part of # the token budget, so a small max_tokens can return an empty # final ``content``. Keep headroom for reasoning + answer. - default_max_tokens=8192, + default_max_tokens=16384, ), ), ), @@ -112,7 +112,7 @@ def as_public_dict(self) -> dict[str, object]: company="DeepSeek", display_name="DeepSeek V4 Flash", # Same reasoning-model note as the OpenRouter DeepSeek route. - default_max_tokens=8192, + default_max_tokens=16384, ), ), ), diff --git a/apps/scut-senior/api/src/scut_senior_api/contracts.py b/apps/scut-senior/api/src/scut_senior_api/contracts.py index 55d9055b..e65e1b6e 100644 --- a/apps/scut-senior/api/src/scut_senior_api/contracts.py +++ b/apps/scut-senior/api/src/scut_senior_api/contracts.py @@ -814,6 +814,15 @@ class AccountDeletionSummary(ContractModel): deleted_at: datetime +class AccountPreferencesUpdate(ContractModel): + """个人中心偏好:键值对,随 GitHub 账号跨设备同步。 + + 键为 theme_mode / accent_theme / answer_mode / tone 等;值为字符串。 + """ + + preferences: dict[str, str] + + class AccountExportContribution(ContractModel): contribution_id: UUID course_id: str diff --git a/apps/scut-senior/api/src/scut_senior_api/course_availability.py b/apps/scut-senior/api/src/scut_senior_api/course_availability.py index 8ea54ba5..7590180d 100644 --- a/apps/scut-senior/api/src/scut_senior_api/course_availability.py +++ b/apps/scut-senior/api/src/scut_senior_api/course_availability.py @@ -8,6 +8,33 @@ RetrievalAvailability = Literal["fixture", "local_corpus", "unavailable"] +CourseCategory = Literal["enabled", "not_enabled", "no_data"] + + +def course_category(data_backed: bool, enabled: bool) -> CourseCategory: + """Single canonical category shared by the course list and the plugin panel. + + ``no_data`` when the configured retrieval adapter cannot serve the course + right now (no active local-corpus data, no fixture coverage). Otherwise + ``enabled`` when the user has its plugin loaded, else ``not_enabled``. + Keeping one derivation here is what stops the two surfaces from drifting. + """ + if not data_backed: + return "no_data" + return "enabled" if enabled else "not_enabled" + + +def course_plugin_data_backed(retrieval_mode: str, state: str) -> bool: + """Whether a plugin-registry ``state`` implies the retrieval adapter has data. + + ``derive_course_plugin_states`` only reports ``active`` for a served + local-corpus course and ``fixture_only`` for fixture coverage, so the data + fact is mode-dependent: local_corpus sees ``active`` as backed, while the + fixture profile sees ``fixture_only`` as backed. + """ + if retrieval_mode == "local_corpus": + return state == "active" + return state == "fixture_only" @dataclass(frozen=True, slots=True) @@ -39,6 +66,10 @@ def as_public_dict(self) -> dict[str, object]: "retrieval_available": self.retrieval_available, "plugin_loaded": self.plugin_loaded, "selectable": self.selectable, + # Single collapsed truth for the UI: ``selectable`` PLUS the + # category both surfaces (course picker and plugin panel) render. + "usable": self.selectable, + "category": course_category(self.retrieval_available, self.plugin_loaded), } diff --git a/apps/scut-senior/api/src/scut_senior_api/credentials.py b/apps/scut-senior/api/src/scut_senior_api/credentials.py index 6e8d1c84..57e60d83 100644 --- a/apps/scut-senior/api/src/scut_senior_api/credentials.py +++ b/apps/scut-senior/api/src/scut_senior_api/credentials.py @@ -48,7 +48,13 @@ class EncryptedCredential: class CredentialCipher: - """AES-256-GCM bound to one user, login session, and provider.""" + """AES-256-GCM bound to one user and provider, not to a login session. + + Cross-device support means the authenticated identity (user_id) is the + binding scope, so a BYOK key saved on one device decrypts on another device + of the same GitHub account. Session activity is still required to *use* the + key; it is no longer part of the ciphertext scope. + """ def __init__(self, master_key: bytes, key_version: int): if len(master_key) != AES_256_KEY_BYTES: @@ -63,7 +69,6 @@ def encrypt( plaintext: str, *, user_id: UUID, - auth_session_id: UUID, provider_id: str, ) -> EncryptedCredential: if not plaintext: @@ -72,7 +77,7 @@ def encrypt( ciphertext = self._aead.encrypt( nonce, plaintext.encode("utf-8"), - _credential_aad(user_id, auth_session_id, provider_id), + _credential_aad(user_id, provider_id), ) return EncryptedCredential(ciphertext, nonce, self.key_version) @@ -81,7 +86,6 @@ def decrypt( encrypted: EncryptedCredential, *, user_id: UUID, - auth_session_id: UUID, provider_id: str, ) -> str: if encrypted.key_version != self.key_version: @@ -94,7 +98,7 @@ def decrypt( plaintext = self._aead.decrypt( encrypted.nonce, encrypted.ciphertext, - _credential_aad(user_id, auth_session_id, provider_id), + _credential_aad(user_id, provider_id), ) decoded = plaintext.decode("utf-8") except (InvalidTag, UnicodeDecodeError): @@ -106,11 +110,9 @@ def decrypt( return decoded -def _credential_aad( - user_id: UUID, auth_session_id: UUID, provider_id: str -) -> bytes: +def _credential_aad(user_id: UUID, provider_id: str) -> bytes: if not provider_id or "\x1f" in provider_id: raise ValueError("provider_id is invalid for credential AAD") return ( - f"scut-senior-byok-v1\x1f{user_id}\x1f{auth_session_id}\x1f{provider_id}" + f"scut-senior-byok-v2\x1f{user_id}\x1f{provider_id}" ).encode("utf-8") diff --git a/apps/scut-senior/api/src/scut_senior_api/main.py b/apps/scut-senior/api/src/scut_senior_api/main.py index 52299143..388564f9 100644 --- a/apps/scut-senior/api/src/scut_senior_api/main.py +++ b/apps/scut-senior/api/src/scut_senior_api/main.py @@ -64,9 +64,14 @@ from .config import Settings LOGGER = logging.getLogger("scut_senior.api") -from .course_availability import derive_course_runtime_availability +from .course_availability import ( + course_category, + course_plugin_data_backed, + derive_course_runtime_availability, +) from .contracts import ( AccountDeletionSummary, + AccountPreferencesUpdate, ContributionDraftSubmit, ContributionPreview, ContributionPreviewRequest, @@ -763,6 +768,28 @@ def delete_account( response.headers["Cache-Control"] = "private, no-store" return response + @app.get("/api/v1/account/preferences") + def get_account_preferences( + user: AuthenticatedPrincipal = Depends(require_github_user), + ) -> dict[str, object]: + """读取当前 GitHub 账号的个人中心偏好(跨设备同步)。""" + response: dict[str, object] = { + "preferences": repository.get_user_preferences(str(user.user_id)) + } + return response + + @app.put("/api/v1/account/preferences") + def set_account_preferences( + payload: AccountPreferencesUpdate, + user: AuthenticatedPrincipal = Depends(require_github_user), + ) -> dict[str, object]: + """保存当前 GitHub 账号的个人中心偏好;键值 upsert,随账号维护。""" + for key, value in payload.preferences.items(): + repository.set_user_preference(str(user.user_id), key, value) + return { + "preferences": repository.get_user_preferences(str(user.user_id)) + } + @app.get("/api/v1/me") def me( user: UserIdentity | AuthenticatedPrincipal = Depends(require_user), @@ -858,13 +885,25 @@ def plugin_registry() -> dict[str, object]: "course_id": state.course_id, "display_name": state.display_name, "state": state.state.value, - "loaded": repository.is_course_plugin_loaded(state.course_id), + "loaded": ( + loaded := repository.is_course_plugin_loaded(state.course_id) + ), + "usable": course_plugin_data_backed( + active_settings.retrieval_mode, state.state.value + ) + and loaded, + "category": course_category( + course_plugin_data_backed( + active_settings.retrieval_mode, state.state.value + ), + loaded, + ), "enabled_workflows": ( [ workflow.value for workflow in state.enabled_workflows ] - if repository.is_course_plugin_loaded(state.course_id) + if loaded else [] ), } diff --git a/apps/scut-senior/api/src/scut_senior_api/model_credentials.py b/apps/scut-senior/api/src/scut_senior_api/model_credentials.py index 506b2299..63aa699d 100644 --- a/apps/scut-senior/api/src/scut_senior_api/model_credentials.py +++ b/apps/scut-senior/api/src/scut_senior_api/model_credentials.py @@ -45,14 +45,19 @@ def list_statuses( self, principal: AuthenticatedPrincipal ) -> list[ModelCredentialStatus]: self._require_active_session(principal) + session_active = self._repository.session_is_active( + principal.user_id, principal.auth_session_id + ) configured = { record.provider_id: record - for record in self._repository.list_model_credentials( - principal.user_id, principal.auth_session_id - ) + for record in self._repository.list_model_credentials(principal.user_id) } return [ - self._status(entry.provider_id.value, configured.get(entry.provider_id.value)) + self._status( + entry.provider_id.value, + configured.get(entry.provider_id.value), + session_active, + ) for entry in self._catalog.entries ] @@ -83,21 +88,20 @@ def replace( encrypted = cipher.encrypt( api_key, user_id=principal.user_id, - auth_session_id=principal.auth_session_id, provider_id=provider_id, ) record = self._repository.upsert_model_credential( user_id=principal.user_id, - auth_session_id=principal.auth_session_id, provider_id=provider_id, ciphertext=encrypted.ciphertext, nonce=encrypted.nonce, algorithm=encrypted.algorithm, key_version=encrypted.key_version, ) - # The repository derives expiry from the still-active session inside - # the same write transaction, so a stale request cannot extend a key. - return self._status(entry.provider_id.value, record) + # The credential is scoped to the user, not the session, so it persists + # across re-login on another device. The active-session check above is + # what authorizes this write. + return self._status(entry.provider_id.value, record, True) def delete( self, principal: AuthenticatedPrincipal, provider_id: str @@ -105,7 +109,7 @@ def delete( self._resolve_provider(provider_id) self._require_active_session(principal) deleted = self._repository.delete_model_credential( - principal.user_id, principal.auth_session_id, provider_id + principal.user_id, provider_id ) if not deleted and not self._repository.session_is_active( principal.user_id, principal.auth_session_id @@ -124,7 +128,7 @@ def load_api_key( detail="用户 API Key 加密服务未配置。", ) record = self._repository.get_model_credential( - principal.user_id, principal.auth_session_id, provider_id + principal.user_id, provider_id ) if record is None: if not self._repository.session_is_active( @@ -134,7 +138,7 @@ def load_api_key( raise ModelCredentialError( status_code=409, code="model_credential_not_configured", - detail="当前登录会话尚未保存该供应商的 API Key。", + detail="当前账号尚未保存该供应商的 API Key。", ) try: api_key = cipher.decrypt( @@ -145,7 +149,6 @@ def load_api_key( algorithm=record.algorithm, ), user_id=principal.user_id, - auth_session_id=principal.auth_session_id, provider_id=provider_id, ) except CredentialDecryptionError: @@ -192,7 +195,10 @@ def _require_enabled_provider(self, provider_id: str): ) from None def _status( - self, provider_id: str, record: StoredModelCredential | None + self, + provider_id: str, + record: StoredModelCredential | None, + session_active: bool, ) -> ModelCredentialStatus: entry = self._catalog.resolve_provider(provider_id) model_id = entry.models[0].model_id @@ -213,9 +219,7 @@ def _status( configured=True, masked_key=MASKED_MODEL_KEY, expires_at=record.expires_at, - writable=self._cipher is not None and self._repository.session_is_active( - record.user_id, record.auth_session_id - ), + writable=self._cipher is not None and session_active, source="user_key", updated_at=record.updated_at, ) diff --git a/apps/scut-senior/api/src/scut_senior_api/ports.py b/apps/scut-senior/api/src/scut_senior_api/ports.py index 063a6324..d9e0f078 100644 --- a/apps/scut-senior/api/src/scut_senior_api/ports.py +++ b/apps/scut-senior/api/src/scut_senior_api/ports.py @@ -92,7 +92,6 @@ def humanize( @dataclass(frozen=True, slots=True) class StoredModelCredential: user_id: UUID - auth_session_id: UUID provider_id: str ciphertext: bytes = field(repr=False) nonce: bytes = field(repr=False) @@ -208,19 +207,16 @@ def set_course_plugin_loaded( class ModelCredentialRepository(Protocol): - def list_model_credentials( - self, user_id: UUID, auth_session_id: UUID - ) -> list[StoredModelCredential]: ... + def list_model_credentials(self, user_id: UUID) -> list[StoredModelCredential]: ... def get_model_credential( - self, user_id: UUID, auth_session_id: UUID, provider_id: str + self, user_id: UUID, provider_id: str ) -> StoredModelCredential | None: ... def upsert_model_credential( self, *, user_id: UUID, - auth_session_id: UUID, provider_id: str, ciphertext: bytes, nonce: bytes, @@ -228,9 +224,7 @@ def upsert_model_credential( key_version: int, ) -> StoredModelCredential: ... - def delete_model_credential( - self, user_id: UUID, auth_session_id: UUID, provider_id: str - ) -> bool: ... + def delete_model_credential(self, user_id: UUID, provider_id: str) -> bool: ... def session_is_active(self, user_id: UUID, auth_session_id: UUID) -> bool: ... diff --git a/apps/scut-senior/api/src/scut_senior_api/service.py b/apps/scut-senior/api/src/scut_senior_api/service.py index dfa0c5b5..4a47e32c 100644 --- a/apps/scut-senior/api/src/scut_senior_api/service.py +++ b/apps/scut-senior/api/src/scut_senior_api/service.py @@ -208,6 +208,13 @@ def load_course_plugin( self, user: RequestIdentity, course_id_or_alias: str ) -> str: course = self.registry.resolve(course_id_or_alias) + # A course the retrieval adapter cannot serve must not become "loaded": + # that is exactly the fork that made the plugin panel and the course + # list disagree (loaded but no data). Fail closed instead. + if not self._retrieval_course_available(course.course_id): + raise CapabilityUnavailable( + "course", "该课程无本地语料数据,无法装载。" + ) self.repository.set_course_plugin_loaded( course.course_id, True, str(user.user_id) ) diff --git a/apps/scut-senior/docs/senior-2/PLAN-2.md b/apps/scut-senior/docs/senior-2/PLAN-2.md index 33d0f353..eb373d6e 100644 --- a/apps/scut-senior/docs/senior-2/PLAN-2.md +++ b/apps/scut-senior/docs/senior-2/PLAN-2.md @@ -1,8 +1,8 @@ # SCUT 老学长二期迭代 PLAN-2:三阶段 SOP(Agent 化与混合检索) -版本:1.0(三阶段 SOP 定型,建议稿) +版本:1.1(三阶段 SOP 定型,口径已按落地实现对齐,建议稿) -状态:**PLAN-1 定义的一期已开发完成**——五类固定 Workflow、HARNESS_REGISTRY 受控工具注册表、确定性词法 RAG、引用/来源 Guard、配额与 BYOK 目录、NDJSON 流式前端均已落地并有测试覆盖。本文是**老学长二期迭代计划**,按三阶段 SOP 落地:地基(统一输入 + 混合检索)、内核(EventStream Agent Loop)、出口(工具服务化与上线标定)。它不替代 PLAN-1 的任何冻结决策;与 PLAN-1 冲突时以 PLAN-1 为准。 +状态:**PLAN-1 定义的一期已开发完成**——五类固定 Workflow、HARNESS_REGISTRY 受控工具注册表、确定性词法 RAG、引用/来源 Guard、配额与 BYOK 目录、NDJSON 流式前端均已落地并有测试覆盖。**PLAN-2 阶段一(统一输入 + 混合检索)与阶段二内核(受限单步决策)已落地**:BM25F 词法腿、本地 ONNX dense 腿 + RRF(k=60)、规则重排、P0 评测基线(46 门 × 30 条)、统一 Composer 自动路由、EventStream Agent 内核(reducer + 动作白名单 + 死循环预算)均已实现并有测试。本文按三阶段 SOP 落地:地基(统一输入 + 混合检索)、内核(EventStream Agent Loop)、出口(工具服务化与上线标定)。执行中由用户拍板的补充/覆盖决策登记在 `DECISIONS.md`(D1–D9),本文 v1.1 已将其并入正文口径。它不替代 PLAN-1 的任何冻结决策;与 PLAN-1 冲突时以 PLAN-1 为准。 > **替代说明**:本文整体替换原 v0.1《检索升级与最小部署配置 PLAN-2(建议稿)》:检索分级改造并入 §3(阶段一),最小部署规格并入 §6,待确认事项并入 §7。 > @@ -12,6 +12,8 @@ > > **v0.5 → v1.0 三阶段 SOP 重组**:原 Phase 1~5 重排为三个阶段——阶段一(统一输入 + 混合检索)、阶段二(EventStream Agent Loop + exam_review 确定性计划)、阶段三(工具服务化 + 上线标定);每阶段按「目标 / 前置依赖 / 实施步骤 / 验收 DoD / 止损回滚点」SOP 模板展开。新增一条贯穿不变量:**二期全程仍不部署服务器**。 > +> **v1.0 → v1.1 口径对齐落地实现**:按 `DECISIONS.md` D1–D9 并入正文——embedding 改用本地 ONNX `bge-small-zh-v1.5`(512 维)而非 API、最终排序改为确定性规则重排(无模型 reranker);P0 基线固定为 46 门 × 30 条 = 1380 条已审核;阶段一只保留 active Hybrid candidate(`previous_corpus_version=null`,本地即时回滚不承诺);阶段二事件词表实为 8 种(含 `action_executed` / `guard_retry_recorded`),NDJSON `agent` 事件默认关闭(`SCUT_SENIOR_AGENT_EVENT_STREAM_ENABLED` 开启后旧客户端仍兼容);统一输入自动路由以确定性正则落地,`讲解形式`/`输出风格`移入助手设置;min_score 重定标为 1.0;模型输出预算默认 max_tokens=16384。 +> > **写作动机**:一期已交付的价值重心在"可验证"一侧——locator 定位合同、引用 Guard、min_score 地板、契约评测全部闭环;已知短板同样明确:召回只有单课程词法加权打分(无 IDF、无语义通道),执行是一次性链路(证据缺口无法驱动下一步动作)。二期据此立两条主线:混合检索补"召回聪明",EventStream Agent Loop 补"证据驱动的动态决策",全程保持一期 harness 边界不动摇。 ## 1. 目标定位 @@ -60,53 +62,55 @@ **步骤 1 —— P0 检索评测基线(一切改造的前置)** -- Golden set:`(course_id, query) → 必须命中的 chunk_id 列表`,来源用历年题题干 → 题目 chunk、知识点名词 → 定义标题 chunk,每门首批课程 ≥30 条,人工核对;存放 `resources/evaluation/retrieval-golden/`,随 Corpus CI 校验引用真实存在; +- Golden set:`(course_id, query) → 必须命中的 chunk_id 列表`,来源用历年题题干 → 题目 chunk、知识点名词 → 定义标题 chunk,**已落地:46 门课程 × 每门 30 条 = 1380 条,已由维护者审核固定为阶段一评测基线(DECISIONS D2)**;存放 `resources/evaluation/retrieval-golden/`,随 Corpus CI 校验引用真实存在(引用缺失 fail-closed); - 指标:recall@5、recall@20、MRR、噪声率(返回但未被回答引用的占比,用于重定标 min_score); -- 落点:eval_runner 增加 `--retrieval-only` 模式与逐课程报告。 +- 落点:eval_runner 增加 `--retrieval-only` 模式与逐课程报告(**已实现**,另产出 `resources/evaluation/retrieval-baseline.json` / `retrieval-comparison.json` 实测报告)。 **步骤 2 —— 词法腿升级为 BM25F** -- 纯算法替换:字段权重 title > heading/question > text,映射现有 ×4/×3/×3/×1 的意图,加饱和抑制; -- 43 门课约 24k chunks 规模纯 Python 倒排毫秒级; +- 纯算法替换:字段权重 title 4.0 > heading 3.0 / question 3.0 > text 1.0(映射现有 ×4/×3/×3/×1 的意图),k1=1.5 / b=0.75 饱和抑制,整句精确命中加固定小 bonus(`EXACT_MATCH_BONUS=1.0`,只打破近邻平局,不主导排序); +- 46 门课约 24.6k chunks 规模纯 Python 倒排毫秒级; - 接口保持 `RetrievalGateway.search(course_ids, query)` 对 service 层透明; -- `min_score` 阈值由步骤 1 的 P0 数据重定标,不沿用旧值 6。 +- `min_score` 阈值由步骤 1 的 P0 数据重定标,**不沿用旧值 6,落地值为 1.0**。 **步骤 3 —— 密集腿(embedding)+ RRF 融合** -- 中文 embedding 二选一:本地 bge-m3 或 API(硅基流动/智谱均有接口,目录加第五类条目);**推荐 API,不本地推理**(见 §6); -- 存储:**sqlite-vec 或 lance 单文件,不用 Qdrant**;向量文件放进 candidate 目录随 activate/rollback 天然获得版本门与回退;validate 校验行数与维度; +- embedding:**本地 ONNX `bge-small-zh-v1.5`(512 维,CPUExecutionProvider,mean-pool + L2 归一化),非 API、非本地大模型推理(DECISIONS D1)**;缺模型文件时回退 BM25F,不发起网络请求; +- 存储:**单文件 SQLite 向量库(stdlib `sqlite3` + 暴力余弦,每课程一个 `.db`,candidate 目录下随 activate 获得版本门)**,不用 Qdrant;`sqlite-vec`/`lance` 是文档化的升级路径,非当前实现;validate 校验行数与维度(`embedding_model_id` 与 provider 不一致 fail-closed); - 融合:两腿各取 top50,**RRF(k=60)** 后取 top-N——只用排名不用分值,无需归一化,保持确定性排序(同输入同输出);两路召回并行执行,T_retrieval ≈ max(词法, 向量) + 合并; -- 版本绑定:`corpus_version` 追加 embedding 模型 id 段,换模型 = 重建 candidate = 重走激活门;RetrievalBatch 校验向量版本与 course_pack_version 同源,不一致按 `ContractConflict` 处理; -- 元数据过滤:course_id、审核状态、corpus_version 为确定性过滤,绝不交给相似度。 +- 版本绑定:`corpus_version` 追加 embedding 模型 id 段(`-e{model_id}`),换模型 = 重建 candidate = 重走激活门;RetrievalBatch 校验向量版本与 course_pack_version 同源,不一致按 `ContractConflict` 处理; +- 元数据过滤:course_id、审核状态、corpus_version 为确定性过滤,绝不交给相似度; +- 阶段一资产口径(DECISIONS D3/D4):只保留 active Hybrid candidate,`previous_corpus_version = null`,本地即时 rollback 不再承诺——需要回退时用 Git 版本回退或从课程资料重建;向量与 ONNX 模型按普通 Git 文件随仓库版本化,不使用 Git LFS;运行 Secret/数据库/日志仍不进 Git。 **步骤 4 —— Query 变体与词表增强** -- exam_review 已有确定性检索词合成;其余 workflow 从 `workflow_payload` 锚点生成 1~3 个规则查询变体,同一轮 RRF; -- 新增每课程**确定性同义词/缩写展开表**(人工维护、可审计),不用 LLM 改写:省一趟调用、不碰"检索词不改课程范围语义"红线;LLM 改写仅作可选开关默认关。 +- exam_review 已有确定性检索词合成;其余 workflow 从 `workflow_payload` 锚点生成 1~3 个规则查询变体,同一轮 RRF(**已实现 `query_variants.py`:原 query + ≤2 个确定性展开,`resources/retrieval/query-expansions.json` 按课程维护,`MAX_QUERY_VARIANTS=3`**); +- 新增每课程**确定性同义词/缩写展开表**(人工维护、可审计),不用 LLM 改写:省一趟调用、不碰"检索词不改课程范围语义"红线;LLM 改写仅作可选开关默认关(当前实现不调 LLM 改写)。 -**步骤 5 —— 重排(可选增强)** +**步骤 5 —— 重排(已落地为确定性规则重排)** -- 召回 top20~50 → reranker → top5 进 prompt;本地 bge-reranker-v2-m3 或 API 二选一,**推荐 API**; -- API 失败时降级回 RRF 顺序继续 run(rerank 是增强不是依赖); -- Trace 记录两腿命中数、融合顺序、rerank 前后顺序;数值只用于候选排序,不解释为概率。 +- 最终排序**不使用模型 reranker,改为确定性规则重排(DECISIONS D1,`rule_rerank.py`)**:BM25F 整句精确命中受保护置前 → 其余词法候选 → dense 仅补位填充未用槽位,不允许 dense 无条件推翻明确的词法命中; +- dense 腿缺失时降级回词法单腿继续 run(重排是增强不是依赖); +- Trace 记录两腿命中数、融合顺序、重排前后顺序;数值只用于候选排序,不解释为概率。 **步骤 6 —— 统一输入与自动路由** -- 增加统一 Composer,Router 输出 `workflow_type + typed_payload + confidence`; -- 置信度低时向用户澄清,路由失败时允许手动纠正; -- 五类 Workflow 保留为受控 Skill(能力入口),复用现有 `WorkflowType` 合同与 payload schema。 +- 增加统一 Composer,Router 输出 `workflow_type + typed_payload + confidence`(**已实现前端 `workflowRouter.ts`:确定性正则识别五类 Workflow,输出 confidence 与原因**); +- 置信度低时回退知识答疑并向用户提示,路由失败时允许在字段抽屉手动纠正(DECISIONS D6); +- 五类 Workflow 保留为受控 Skill(能力入口),复用现有 `WorkflowType` 合同与 payload schema; +- `讲解形式` 与 `输出风格` 移入个人中心助手设置,持久化在本机浏览器(DECISIONS D6)。 ### 3.2 验收(DoD) -- recall@K 与 MRR 提升;题号/公式/函数名精确命中率不回退;语义改写命中率提升; -- 课程越权候选数 = 0;索引版本切换与回滚可用; +- recall@K 与 MRR 提升;题号/公式/函数名精确命中率不回退;语义改写命中率提升(**实测:hybrid recall@5 0.638 / recall@20 0.861 / MRR 0.462,均高于 BM25F 单腿,见 `resources/evaluation/retrieval-comparison.json`**); +- 课程越权候选数 = 0;索引版本切换与回滚可用(**阶段一只保留 active candidate,回滚走 Git/重建,见 DECISIONS D3/D4**); - 路由分类准确率、payload schema 通过率、低置信度误执行率、用户纠正率达标; - **不部署服务器核对**:无新增常驻进程、无新增独立存储服务,向量文件为单文件随 candidate 版本门管理。 ### 3.3 止损/回滚点 - BM25F 若精确命中率回退 → 回退纯词法加权打分,保留评测基线继续调权; -- 向量腿若引入越权候选或 ContractConflict 频发 → 关闭 dense 腿,降级回单腿; +- 向量腿若引入越权候选或 ContractConflict 频发 → 关闭 dense 腿,降级回单腿(**缺 ONNX 模型文件/向量时自动回退 BM25F,见 DECISIONS D1**); - 路由误执行率超阈值 → 恢复手动选 Workflow 入口,统一 Composer 转可选。 --- @@ -126,20 +130,20 @@ → observation_recorded → 回到 reducer ``` -1. 扩充 NDJSON 事件词表:`decision_produced / action_rejected / observation_recorded / budget_crossed / clarification_requested / run_finished`; -2. 服务端纯 reducer:`state = reduce_agent_event(state, event)`,与前端 `reduceWorkflowStreamEvent` 同构;测试方法为喂事件序列断言终态; -3. 事件追加式写入 SQLite 事件日志,**终态快照必须等于事件重放结果**; +1. 扩充 NDJSON 事件词表:`decision_produced / action_rejected / observation_recorded / budget_crossed / clarification_requested / run_finished / action_executed / guard_retry_recorded`(**落地 8 种,见 `agent_loop.py` `EventKind`**;终态集合 `running / finished / budget_exhausted / rejected / interrupted / timed_out / failed`); +2. 服务端纯 reducer:`state = reduce_agent_event(state, event)`,与前端 `reduceWorkflowStreamEvent` 同构;测试方法为喂事件序列断言终态(**已落地并有测试覆盖**); +3. 事件追加式写入 SQLite 事件日志,**终态快照必须等于事件重放结果**(**已落地:`append_agent_event` 原子写入事件 + 派生快照,重放校验不一致即报错,见 DECISIONS D9**); 4. 取消实现为注入的取消事件,由 reducer 在节点边界收敛为 `interrupted`; 5. 同会话请求串行排队,复用 `try_claim_step_start / try_claim_terminal` 单飞语义; -6. 新增事件 kind 走协议版本协商或特性开关,保证旧客户端兼容; -7. 动作白名单首批:`retrieve / retrieve_with_query_rewrite / ask_clarification / generate_answer / finish`; +6. 新增事件 kind 走协议版本协商或特性开关,保证旧客户端兼容(**已落地:对外 NDJSON 的 `agent` 事件默认关闭,`SCUT_SENIOR_AGENT_EVENT_STREAM_ENABLED` 开启后旧客户端仍消费 `trace / answer_delta / result / error`,见 DECISIONS D9**); +7. 动作白名单首批:`retrieve / retrieve_with_query_rewrite / ask_clarification / generate_answer / finish`(**已落地 `ACTION_KINDS`,非法动作在 reducer 内 fail-closed**); 8. 不过度事件溯源:事件只在单个 run 生命周期内是真相源,对外查询以终态快照为准。 ### 4.2 预算:防死循环是本职,配额另层管 **原则**:Agent 预算只防循环失控。自然输入输出不由 Agent 限额——输入由请求合同管(question ≤2 万字符、problem ≤4 万字符、材料 ≤10 万字符),输出由供应商调用参数(max_tokens)管。 -**死循环防线**(所有模型一致,Agent Runtime 执行): +**死循环防线**(所有模型一致,Agent Runtime 执行;**数值已落地为 `AgentBudget` 默认值**): ```text max_steps = 4 # 每次模型交互都算一步,含 Final 与 Guard 重试 @@ -161,7 +165,7 @@ max_runtime_seconds = 120 # 进程级防悬挂兜底,不是成本控 额度耗尽 = 明确报错,不自动切换(PLAN-1 §1.6 冻结) ``` -**BYOK / 其他模型**:只有死循环防线;输入输出不设 Agent 限额。 +**BYOK / 其他模型**:只有死循环防线;输入输出不设 Agent 限额。模型输出预算由供应商调用参数管:OpenRouter 结构化请求与 BYOK 目录 DeepSeek 默认 `max_tokens=16384`(推理模型把部分预算用于 reasoning,需为正文留出空间)。 终止条件:证据覆盖达标;已生成通过 Guard 的回答;模型输出 Final;触达步数或运行时限;同动作重复失败;Guard 重试达上限;用户取消;检测到越权动作。预算到达返回有边界的降级结果(如 `insufficient_evidence`)。 @@ -172,11 +176,11 @@ max_runtime_seconds = 120 # 进程级防悬挂兜底,不是成本控 ### 4.4 exam_review 确定性计划确认 -- `exam_review` 由代码根据大纲、薄弱点和历年题事实生成短计划,零额外模型调用; -- 计划先展示给用户确认,再进入同一个 EventStream Agent Loop; +- `exam_review` 由代码根据大纲、薄弱点和历年题事实生成短计划,零额外模型调用(**已落地 `exam_review.py`,plan_version=`exam-review-plan-v1`,含计划预览接口与决策记录接口**); +- 计划先展示给用户确认,再进入同一个 EventStream Agent Loop(**已落地:`/api/v1/exam-review/plan/preview` 预览、`/plan/decision` 记录 confirmed/edited/rejected、`/plan/confirm` 执行,DB 迁移 0013 建表**); - 计划只影响检索顺序和覆盖目标,不新增工具,不改变 Agent Runtime; - Observation 只更新覆盖率与缺失主题,后续动作仍受本阶段单步决策和死循环防线限制; -- Hook 延伸:`observation_recorded` 后自动更新覆盖率,`action_rejected` 自动埋 rejection 指标。 +- Hook 延伸:`observation_recorded` 后自动更新覆盖率,`action_rejected` 自动埋 rejection 指标(**reducer 已计数 rejection_count / observation_count**)。 ### 4.5 成本与延迟预估(估算口径,上线前压测标定) @@ -195,7 +199,7 @@ max_runtime_seconds = 120 # 进程级防悬挂兜底,不是成本控 - 证据不足能补一次检索、充分不空转;普通问题模型调用通常 1~2 次、最坏 ≤3 次; - 非法工具/跨课程参数全部拒绝并留 `action_rejected` 事件; - 取消/超时/预算进入终态且事件日志完整;终态快照与事件重放一致;旧客户端在新事件流下不崩溃; -- exam_review 计划生成零额外模型调用;用户确认/修改/拒绝路径可审计;计划主题有课程证据或明确标记未覆盖; +- exam_review 计划生成零额外模型调用;用户确认/修改/拒绝路径可审计;计划主题有课程证据或明确标记未覆盖(**预览 + 决策记录已落地,见 §4.4**); - **不部署服务器核对**:EventStream 为进程内事件 + SQLite 追加写,不新增常驻服务或消息队列。 ### 4.7 止损/回滚点 @@ -246,21 +250,21 @@ max_runtime_seconds = 120 # 进程级防悬挂兜底,不是成本控 二期全程坚持"小机器只编排、不推理"边界,部署面不变: - **一期形态维持 1C2G / 40GB / 1–2Mbps 基线**(Makefile `serve-online` 单机单进程路径); -- 完成阶段一推荐组合 **API embedding + API rerank + sqlite-vec**:增量约 2 vCPU / 内存维持 2GB(24k × 1024 维 fp32 mmap 约 100MB 级)/ 磁盘量级不变 / 外部依赖两个 API 配额; -- 本地跑 bge-m3 + reranker 需 4C8G 起步,**不推荐**——ECS 不承担 embedding/索引构建/重排推理; +- 阶段一已落地组合 **本地 ONNX bge-small-zh-v1.5(512 维)+ 规则重排 + SQLite 单文件向量库**:CPUExecutionProvider 推理(24k × 512 维 fp32 约 50MB 级),内存维持 2GB 量级,磁盘量级不变,无新增外部 API 配额依赖(DECISIONS D1);`sqlite-vec`/`lance` 为升级路径,非当前部署依赖; +- 本地跑 bge-m3 + reranker 需 4C8G 起步,**不推荐**——ECS 不承担大模型 embedding/索引构建/重排推理; - 面向真实学生开放时带宽先于 CPU 成为瓶颈:建议 5Mbps 起或将 SPA 静态资源 CDN 前置; - 阶段二的 EventStream 为进程内事件与 SQLite 追加写,不改变部署形态; -- **明确不因二期引入**:PostgreSQL、Qdrant 独立服务、对象存储、任务队列、MCP 常驻服务、本地推理节点——升级后部署面仍是"一台小机器 + 一组文件"。 +- **明确不因二期引入**:PostgreSQL、Qdrant 独立服务、对象存储、任务队列、MCP 常驻服务、本地大模型推理节点——升级后部署面仍是"一台小机器 + 一组文件"。 ## 7. 待确认事项 -1. Golden set 人工标注的人力归属(资料 A/B 还是开发组); -2. embedding/rerank 走 API 时挂靠哪家供应商、并入 BYOK 目录还是平台目录新增分类; -3. 阶段一起 corpus-active-v1 是否升版为 v2,旧 store 只允许重建还是提供迁移工具; -4. rerank 降级是否学生端可见(建议仅 Trace 可见); -5. 阶段二流事件协议版本号方案与旧客户端兼容窗口; -6. 澄清(clarification)交互形态与滚动摘要的字段边界; -7. §4.2 运行时限与平台配额数值、§4.5 成本表在上线前经压测标定,当前值为设计上限。 +1. Golden set 人工标注的人力归属(资料 A/B 还是开发组)——**已定(D2)**:46 门 × 30 条已由维护者审核固定为基线; +2. embedding/rerank 走 API 时挂靠哪家供应商、并入 BYOK 目录还是平台目录新增分类——**已定(D1)**:不走 API,本地 ONNX bge-small-zh-v1.5 + 规则重排; +3. 阶段一起 corpus-active-v1 是否升版为 v2,旧 store 只允许重建还是提供迁移工具——**已定(D3/D4)**:只保留 active Hybrid candidate,`previous_corpus_version=null`,回退走 Git/重建; +4. rerank 降级是否学生端可见(建议仅 Trace 可见)——**已定(D1)**:最终排序为确定性规则重排,dense 缺失时静默回退词法单腿; +5. 阶段二流事件协议版本号方案与旧客户端兼容窗口——**已定(D9)**:NDJSON `agent` 事件默认关闭,`SCUT_SENIOR_AGENT_EVENT_STREAM_ENABLED` 开启后旧客户端仍兼容; +6. 澄清(clarification)交互形态与滚动摘要的字段边界——**部分已定(D6)**:路由失败在字段抽屉手动纠正;滚动摘要字段边界随阶段二后段推进; +7. §4.2 运行时限与平台配额数值、§4.5 成本表在上线前经压测标定,当前值为设计上限(**未变,仍待阶段三压测**)。 ## 8. 借鉴取舍总览 @@ -277,8 +281,8 @@ max_runtime_seconds = 120 # 进程级防悬挂兜底,不是成本控 | Agent Loop | 见 §4 EventStream Agent Loop | Observe/Decide/Act ↔ observation_recorded / decision_produced / 受控执行 | 独立 ReAct 框架、全局 Planner | | Compaction | 字段级长度上限 | 证据账本去重、候选降级、轮次滚入摘要 | 语义压缩模型 | -**P0(挂阶段一/二)**:RRF 融合;embedding 身份入索引版本;证据账本去重 + 候选降级摘要;结构化滚动摘要。 -**P1(挂阶段二)**:exam_review 计划确认;同义词展开表;Hook 延伸埋 rejection 指标。 +**P0(挂阶段一/二)**:RRF 融合(✅);embedding 身份入索引版本(✅);证据账本去重 + 候选降级摘要(✅);结构化滚动摘要(阶段二后段)。 +**P1(挂阶段二)**:exam_review 计划确认(✅);同义词展开表(✅);Hook 延伸埋 rejection 指标(✅ reducer 计数)。 **明确不借**:独立 LLM Planner、ReAct 框架、Replan 循环、Subagent、运行时审批弹窗、MCP(现阶段)、向量库存对话、语义压缩模型、自动模型路由;继承冻结:agent 自主多跳检索、语义缓存、跨课程检索开放。 来源标注:DSH(goal/budget 边界、spill 文件、结构化 todo)、Claude Code(auto-compact、plan mode)、Codex(AGENTS.md 约定、沙箱 fail-closed)均取公开资料口径的机制思想,不冒称了解各家内部实现细节。 diff --git a/apps/scut-senior/tests/python/test_account_lifecycle.py b/apps/scut-senior/tests/python/test_account_lifecycle.py index b93a1fd2..97718953 100644 --- a/apps/scut-senior/tests/python/test_account_lifecycle.py +++ b/apps/scut-senior/tests/python/test_account_lifecycle.py @@ -160,7 +160,6 @@ def seed_account_data(app, client: TestClient, *, with_credential: bool) -> None session = repository.issue_session(UUID(alice_user_id)) repository.upsert_model_credential( user_id=UUID(alice_user_id), - auth_session_id=session.auth_session_id, provider_id="openrouter", ciphertext=b"0123456789abcdef0123456789abcdef", # 模拟密文 nonce=b"0123456789ab", diff --git a/apps/scut-senior/tests/python/test_account_preferences.py b/apps/scut-senior/tests/python/test_account_preferences.py new file mode 100644 index 00000000..d18db927 --- /dev/null +++ b/apps/scut-senior/tests/python/test_account_preferences.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from scut_senior_api.auth import GitHubUserProfile, SESSION_COOKIE_NAME +from scut_senior_api.config import Settings +from scut_senior_api.main import create_app + + +def _oauth_settings(database_path: Path) -> Settings: + return Settings( + app_env="test", + identity_mode="github_oauth", + storage_mode="sqlite", + database_path=database_path, + github_client_id="prefs-client", + github_client_secret="prefs-secret", + github_callback_url="https://testserver/api/v1/auth/github/callback", + post_login_redirect_url="https://testserver/", + ) + + +def _login(app, github_id: int, login: str) -> tuple[TestClient, str]: + repository = app.state.repository + user_id = repository.upsert_github_user(GitHubUserProfile(github_id, login)) + session = repository.issue_session(user_id) + client = TestClient(app, base_url="https://testserver") + client.cookies.set(SESSION_COOKIE_NAME, session.token, path="/") + return client, session.token + + +def test_account_preferences_require_github_login(tmp_path: Path) -> None: + app = create_app(_oauth_settings(tmp_path / "prefs.db")) + client = TestClient(app, base_url="https://testserver") + assert client.get("/api/v1/account/preferences").status_code == 401 + assert client.put( + "/api/v1/account/preferences", json={"preferences": {"tone": "study_partner"}} + ).status_code == 401 + + +def test_preferences_survive_relogin_and_are_user_scoped(tmp_path: Path) -> None: + database_path = tmp_path / "prefs.db" + app = create_app(_oauth_settings(database_path)) + first, _ = _login(app, 1001, "prefs-user") + + saved = first.put( + "/api/v1/account/preferences", + json={ + "preferences": { + "theme_mode": "1", + "accent_theme": "indigo", + "answer_mode": "concise", + "tone": "study_partner", + } + }, + ) + assert saved.status_code == 200, saved.text + first_prefs = saved.json()["preferences"] + assert first_prefs["theme_mode"] == "1" + assert first_prefs["tone"] == "study_partner" + + # A second session for the same GitHub account sees the same preferences + # (cross-device). Logout then re-login to force a new auth_session_id. + assert first.post("/api/v1/auth/logout").status_code == 200 + second, _ = _login(app, 1001, "prefs-user") + seen = second.get("/api/v1/account/preferences") + assert seen.status_code == 200 + assert seen.json()["preferences"]["accent_theme"] == "indigo" + assert seen.json()["preferences"]["answer_mode"] == "concise" + + # Another GitHub account is isolated. + other, _ = _login(app, 1002, "other") + other_prefs = other.get("/api/v1/account/preferences") + assert other_prefs.status_code == 200 + assert other_prefs.json()["preferences"] == {} diff --git a/apps/scut-senior/tests/python/test_byok_runtime.py b/apps/scut-senior/tests/python/test_byok_runtime.py index 60dd5e0c..454be3c9 100644 --- a/apps/scut-senior/tests/python/test_byok_runtime.py +++ b/apps/scut-senior/tests/python/test_byok_runtime.py @@ -564,9 +564,11 @@ def revoke_during_call() -> HttpResponse: assert connection.execute( "SELECT COUNT(*) FROM workflow_runs" ).fetchone()[0] == 0 + # Cross-device BYOK: the key is per-account, so revoking this session + # must NOT clear the user's saved credential. assert connection.execute( "SELECT COUNT(*) FROM model_credentials" - ).fetchone()[0] == 0 + ).fetchone()[0] == 1 def test_test_profile_without_injected_byok_transport_fails_closed( diff --git a/apps/scut-senior/tests/python/test_harness_registry.py b/apps/scut-senior/tests/python/test_harness_registry.py index 455e7c6c..2ca0597a 100644 --- a/apps/scut-senior/tests/python/test_harness_registry.py +++ b/apps/scut-senior/tests/python/test_harness_registry.py @@ -343,12 +343,16 @@ def test_courses_endpoint_exposes_runtime_selection_gates(tmp_path: Path) -> Non "retrieval_available": True, "plugin_loaded": True, "selectable": True, + "usable": True, + "category": "enabled", } assert courses["cpp"]["mock_available"] is False assert courses["cpp"]["retrieval_availability"] == "unavailable" assert courses["cpp"]["retrieval_available"] is False assert courses["cpp"]["plugin_loaded"] is True assert courses["cpp"]["selectable"] is False + assert courses["cpp"]["usable"] is False + assert courses["cpp"]["category"] == "no_data" app.state.repository.set_course_plugin_loaded("linear_algebra", False) after_unload = client.get("/api/v1/courses").json() @@ -360,6 +364,8 @@ def test_courses_endpoint_exposes_runtime_selection_gates(tmp_path: Path) -> Non assert linear_algebra["retrieval_available"] is True assert linear_algebra["plugin_loaded"] is False assert linear_algebra["selectable"] is False + assert linear_algebra["usable"] is False + assert linear_algebra["category"] == "not_enabled" def test_plugin_registry_endpoint_reports_honest_metadata(tmp_path: Path) -> None: @@ -396,8 +402,14 @@ def test_plugin_registry_endpoint_reports_honest_metadata(tmp_path: Path) -> Non assert len(courses) == 55 assert courses["linear_algebra"]["state"] == "fixture_only" assert courses["linear_algebra"]["enabled_workflows"] == [] + # fixture profile: fixture_only is the data-backed state; with the plugin + # loaded by default the category collapses to "enabled". + assert courses["linear_algebra"]["usable"] is True + assert courses["linear_algebra"]["category"] == "enabled" assert courses["cpp"]["state"] == "registered" assert courses["cpp"]["enabled_workflows"] == [] + assert courses["cpp"]["usable"] is False + assert courses["cpp"]["category"] == "no_data" serialized = json.dumps(body) for forbidden in ("prompt", "directive", "authoritative_query", "anchor_context"): @@ -606,3 +618,10 @@ def test_course_plugin_load_unload_persists_and_gates_runtime(tmp_path: Path) -> unknown = client.post("/api/v1/plugin-registry/courses/not-a-course/load") assert unknown.status_code == 422 assert unknown.json()["error"]["code"] == "unknown_course" + + # Loading a course with no retrieval data fails closed instead of creating + # the "loaded but unusable" fork between the plugin panel and course list. + no_data = client.post("/api/v1/plugin-registry/courses/cpp/load") + assert no_data.status_code == 503 + assert no_data.json()["error"]["code"] == "capability_unavailable" + assert no_data.json()["error"]["capability"] == "course" diff --git a/apps/scut-senior/tests/python/test_model_credentials.py b/apps/scut-senior/tests/python/test_model_credentials.py index 45b64011..7aa7c83f 100644 --- a/apps/scut-senior/tests/python/test_model_credentials.py +++ b/apps/scut-senior/tests/python/test_model_credentials.py @@ -2,7 +2,6 @@ import base64 import sqlite3 -import threading from datetime import UTC, datetime, timedelta from pathlib import Path @@ -82,23 +81,20 @@ def test_master_key_is_strict_aes256_base64_and_never_appears_in_repr( ).assert_safe() -def test_aesgcm_detects_tampering_and_binds_user_session_and_provider() -> None: +def test_aesgcm_detects_tampering_and_binds_user_and_provider() -> None: from uuid import uuid4 cipher = CredentialCipher(MASTER_KEY_BYTES, 7) user_id = uuid4() - session_id = uuid4() encrypted = cipher.encrypt( "sk-private", user_id=user_id, - auth_session_id=session_id, provider_id="openrouter", ) assert cipher.decrypt( encrypted, user_id=user_id, - auth_session_id=session_id, provider_id="openrouter", ) == "sk-private" assert "sk-private" not in repr(encrypted) @@ -122,21 +118,19 @@ def test_aesgcm_detects_tampering_and_binds_user_session_and_provider() -> None: cipher.decrypt( candidate, user_id=user_id, - auth_session_id=session_id, provider_id="openrouter", ) + # Cross-device: the AAD binds user_id + provider_id, not a login session. with pytest.raises(CredentialDecryptionError): cipher.decrypt( encrypted, - user_id=user_id, - auth_session_id=uuid4(), + user_id=uuid4(), provider_id="openrouter", ) with pytest.raises(CredentialDecryptionError): cipher.decrypt( encrypted, user_id=user_id, - auth_session_id=session_id, provider_id="deepseek", ) @@ -223,7 +217,9 @@ def test_crud_returns_only_masked_metadata_and_database_contains_only_aead( assert len(bytes(row["nonce"])) == 12 assert row["algorithm"] == CREDENTIAL_ALGORITHM assert row["key_version"] == 7 - assert row["expires_at"] == session_expiry + # Per-user credentials expire on a fixed long horizon, not with the session. + assert row["expires_at"] != session_expiry + assert datetime.fromisoformat(row["expires_at"]) > datetime.now(UTC) assert secret.encode() not in database_path.read_bytes() @@ -260,10 +256,12 @@ def test_replace_restart_same_session_and_new_session_isolation(tmp_path: Path) new_session = restarted.state.repository.issue_session(user_id) other_tab = TestClient(restarted, base_url="https://testserver") other_tab.cookies.set(SESSION_COOKIE_NAME, new_session.token, path="/") - assert all( - item["configured"] is False + # Cross-device: a different session of the same GitHub account sees the key. + assert next( + item for item in other_tab.get("/api/v1/model-credentials").json() - ) + if item["provider_id"] == "deepseek" + )["configured"] is True def test_logout_delete_expiry_and_restore_physically_remove_credentials( @@ -286,9 +284,10 @@ def test_logout_delete_expiry_and_restore_physically_remove_credentials( ).status_code == 200 assert client.post("/api/v1/auth/logout").status_code == 200 with sqlite3.connect(database_path) as connection: + # Cross-device: the key belongs to the account and survives one logout. assert connection.execute( "SELECT COUNT(*) FROM model_credentials" - ).fetchone()[0] == 0 + ).fetchone()[0] == 1 expiring, _ = authenticated_client(app, github_id=202, login="expiring") assert expiring.put( @@ -297,9 +296,10 @@ def test_logout_delete_expiry_and_restore_physically_remove_credentials( clock.advance(timedelta(days=7)) assert expiring.get("/api/v1/model-credentials").status_code == 401 with sqlite3.connect(database_path) as connection: + # Credentials persist per-account even after the session expires. assert connection.execute( "SELECT COUNT(*) FROM model_credentials" - ).fetchone()[0] == 0 + ).fetchone()[0] == 2 fresh, _ = authenticated_client(app, github_id=303, login="backup") assert fresh.put( @@ -318,7 +318,7 @@ def test_logout_delete_expiry_and_restore_physically_remove_credentials( ).fetchone()[0] == 0 assert connection.execute( "SELECT COUNT(*) FROM model_credentials" - ).fetchone()[0] == 0 + ).fetchone()[0] == 3 def test_provider_and_base_url_contract_rejects_secret_without_reflection( @@ -364,53 +364,26 @@ def test_stale_principal_is_revalidated_before_credential_write(tmp_path: Path) ).fetchone()[0] == 0 -def test_revoke_racing_credential_replace_always_leaves_no_ciphertext( - tmp_path: Path, -) -> None: +def test_revoke_after_replace_persists_per_user_credential(tmp_path: Path) -> None: app = create_app(byok_settings(tmp_path / "replace-race.db")) repository = app.state.repository - for ordinal in range(12): - user_id = repository.upsert_github_user( - GitHubUserProfile(7000 + ordinal, f"race-{ordinal}") - ) - session = repository.issue_session(user_id) - principal = repository.authenticate_session(session.token) - assert principal is not None - barrier = threading.Barrier(2) - unexpected: list[BaseException] = [] - - def replace() -> None: - barrier.wait() - try: - app.state.credential_manager.replace( - principal, - "openrouter", - ModelCredentialUpsert(api_key=f"sk-race-{ordinal}"), - ) - except AuthRequired: - pass - except BaseException as exc: # pragma: no cover - diagnostic capture - unexpected.append(exc) - - def revoke() -> None: - barrier.wait() - try: - repository.revoke_session(session.token) - except BaseException as exc: # pragma: no cover - diagnostic capture - unexpected.append(exc) - - writer = threading.Thread(target=replace) - revoker = threading.Thread(target=revoke) - writer.start() - revoker.start() - writer.join(timeout=10) - revoker.join(timeout=10) - assert not writer.is_alive() and not revoker.is_alive() - assert unexpected == [] - - with sqlite3.connect(app.state.settings.database_path) as connection: - assert connection.execute( - "SELECT COUNT(*) FROM model_credentials WHERE auth_session_id = ?", - (str(session.auth_session_id),), - ).fetchone()[0] == 0 + user_id = repository.upsert_github_user(GitHubUserProfile(7000, "race")) + session = repository.issue_session(user_id) + principal = repository.authenticate_session(session.token) + assert principal is not None + + status = app.state.credential_manager.replace( + principal, + "openrouter", + ModelCredentialUpsert(api_key="sk-race"), + ) + assert status.configured is True + # Cross-device: revoking the session that wrote the key must not clear the + # account's credential (it is not session-bound anymore). + assert repository.revoke_session(session.token) is True + with sqlite3.connect(app.state.settings.database_path) as connection: + assert connection.execute( + "SELECT COUNT(*) FROM model_credentials WHERE user_id = ? AND provider_id = ?", + (str(user_id), "openrouter"), + ).fetchone()[0] == 1 diff --git a/apps/scut-senior/tests/python/test_openrouter_models.py b/apps/scut-senior/tests/python/test_openrouter_models.py index d49ff8a7..d3812586 100644 --- a/apps/scut-senior/tests/python/test_openrouter_models.py +++ b/apps/scut-senior/tests/python/test_openrouter_models.py @@ -464,7 +464,7 @@ def test_openrouter_uses_one_exact_model_without_a_structured_output_contract( assert "fallbacks" not in payload assert "provider" not in payload assert "response_format" not in payload - assert payload["max_tokens"] == 8192 + assert payload["max_tokens"] == 16384 assert call["headers"]["Authorization"] == "Bearer server-only-secret" assert "server-only-secret" not in json.dumps(payload, ensure_ascii=False) diff --git a/apps/scut-senior/tests/python/test_sqlite_auth.py b/apps/scut-senior/tests/python/test_sqlite_auth.py index e5dc866c..611a8755 100644 --- a/apps/scut-senior/tests/python/test_sqlite_auth.py +++ b/apps/scut-senior/tests/python/test_sqlite_auth.py @@ -71,6 +71,8 @@ def test_auth_migrations_are_ledgered_and_sqlite_runtime_pragmas_are_enabled( "0011_account_lifecycle.sql", "0012_agent_events.sql", "0013_exam_plan_decisions.sql", + "0014_byok_cross_device.sql", + "0015_user_preferences.sql", ] assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1 assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal" @@ -241,16 +243,42 @@ def test_legacy_0004_schema_is_rebuilt_without_removed_providers_or_extra_column assert "legacy_unused" not in columns assert "retired_provider" not in table_sql - assert providers == ["deepseek", "openrouter"] + # Migration 0013 resets credentials: the AES-GCM AAD changed from + # session-bound to user-bound, so any legacy ciphertext is undecryptable + # and the table is rebuilt empty (users re-enter their key once). + assert providers == [] assert "0005_finalize_model_credentials.sql" in migrations + assert "0014_byok_cross_device.sql" in migrations + # Cross-device BYOK: credentials are per-account, not per-session, so + # revoking one session must NOT delete the user's saved keys. + connection.execute( + """ + INSERT INTO model_credentials ( + user_id, provider_id, ciphertext, nonce, algorithm, + key_version, created_at, updated_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(user_id), + "deepseek", + sqlite3.Binary(bytes([1]) * 17), + sqlite3.Binary(bytes([1]) * 12), + "AES-256-GCM", + 1, + now, + now, + "2099-01-01T00:00:00+00:00", + ), + ) connection.execute( "UPDATE auth_sessions SET revoked_at = ? WHERE auth_session_id = ?", (now, str(principal.auth_session_id)), ) - assert connection.execute( - "SELECT COUNT(*) FROM model_credentials" - ).fetchone()[0] == 0 + assert ( + connection.execute("SELECT COUNT(*) FROM model_credentials").fetchone()[0] + == 1 + ) def test_oauth_state_is_digest_only_ten_minute_and_one_time(tmp_path: Path) -> None: diff --git a/apps/scut-senior/web/src/__tests__/api.test.ts b/apps/scut-senior/web/src/__tests__/api.test.ts index 44621c40..50fb929a 100644 --- a/apps/scut-senior/web/src/__tests__/api.test.ts +++ b/apps/scut-senior/web/src/__tests__/api.test.ts @@ -438,6 +438,8 @@ describe("plugin registry API", () => { display_name: "C++(上及下)", state: "registered", loaded: true, + usable: false, + category: "no_data", enabled_workflows: [], }, ], diff --git a/apps/scut-senior/web/src/__tests__/courseAvailability.test.ts b/apps/scut-senior/web/src/__tests__/courseAvailability.test.ts index 1323a7cc..581d8607 100644 --- a/apps/scut-senior/web/src/__tests__/courseAvailability.test.ts +++ b/apps/scut-senior/web/src/__tests__/courseAvailability.test.ts @@ -19,6 +19,8 @@ const fixtureCourse: Course = { retrieval_available: true, plugin_loaded: true, selectable: true, + usable: true, + category: "enabled", }; const localCorpusCourse: Course = { @@ -31,6 +33,8 @@ const localCorpusCourse: Course = { retrieval_available: true, plugin_loaded: true, selectable: true, + usable: true, + category: "enabled", }; const unavailableCourse: Course = { @@ -43,6 +47,8 @@ const unavailableCourse: Course = { retrieval_available: false, plugin_loaded: true, selectable: false, + usable: false, + category: "no_data", }; describe("course runtime availability", () => { @@ -65,7 +71,7 @@ describe("course runtime availability", () => { it("区分 Fixture、已激活本地语料和不可用课程", () => { expect(courseAvailabilitySummary(fixtureCourse)).toBe("合成 Fixture · 当前可用"); expect(courseAvailabilitySummary(localCorpusCourse)).toBe("已激活本地课程语料 · 当前可用"); - expect(courseAvailabilitySummary(unavailableCourse)).toBe("课程资料未激活或不可用"); + expect(courseAvailabilitySummary(unavailableCourse)).toBe("无本地语料数据"); expect(courseOptionLabel(localCorpusCourse)).toContain("已激活本地课程语料"); expect(courseSelectionError(unavailableCourse)).toBe("该课程资料当前未激活或不可用。"); }); diff --git a/apps/scut-senior/web/src/api.ts b/apps/scut-senior/web/src/api.ts index fb6847e5..5c19ae63 100644 --- a/apps/scut-senior/web/src/api.ts +++ b/apps/scut-senior/web/src/api.ts @@ -131,6 +131,19 @@ export async function unloadCoursePlugin(courseId: string): Promise<{ course_id: ); } +export async function getAccountPreferences(): Promise<{ preferences: Record }> { + return apiRequest<{ preferences: Record }>("/api/v1/account/preferences"); +} + +export async function saveAccountPreferences( + preferences: Record, +): Promise<{ preferences: Record }> { + return apiRequest<{ preferences: Record }>("/api/v1/account/preferences", { + method: "PUT", + body: JSON.stringify({ preferences }), + }); +} + export async function getByokCredentials(): Promise { return apiRequest("/api/v1/model-credentials"); } diff --git a/apps/scut-senior/web/src/components/Composer.vue b/apps/scut-senior/web/src/components/Composer.vue index 1108188c..27dd909d 100644 --- a/apps/scut-senior/web/src/components/Composer.vue +++ b/apps/scut-senior/web/src/components/Composer.vue @@ -1,6 +1,10 @@