diff --git a/apps/scut-senior/.env.example b/apps/scut-senior/.env.example index 47fee2aa..faa94014 100644 --- a/apps/scut-senior/.env.example +++ b/apps/scut-senior/.env.example @@ -30,11 +30,10 @@ SCUT_SENIOR_STORAGE_MODE=sqlite_mock # fixed at 7 days, and the database stores token digests rather than raw tokens. # SCUT_SENIOR_STORAGE_MODE=sqlite SCUT_SENIOR_DATABASE_PATH=.local/iteration-zero.db -# The default remains the synthetic Fixture. local_corpus is only valid after -# a candidate source commit has reached trusted master and a separate guarded -# activation created the store; the store path must be absolute. -SCUT_SENIOR_RETRIEVAL_MODE=fixture -# SCUT_SENIOR_RETRIEVAL_MODE=local_corpus +# Use the activated real corpus by default. Fixture mode is reserved for +# isolated tests and must be explicitly selected when needed. +SCUT_SENIOR_RETRIEVAL_MODE=local_corpus +# SCUT_SENIOR_RETRIEVAL_MODE=fixture # Dense retrieval is enabled by default; it activates when this local ONNX # directory exists and contains model.onnx + tokenizer.json. # SCUT_SENIOR_ONNX_MODEL_PATH=/absolute/path/to/bge-small-zh-v1.5 @@ -42,5 +41,7 @@ SCUT_SENIOR_RETRIEVAL_MODE=fixture # SCUT_SENIOR_ONNX_EMBEDDING_DIMENSIONS=512 # SCUT_SENIOR_ONNX_MAX_LENGTH=512 # SCUT_SENIOR_CORPUS_STORE_PATH=/absolute/path/to/corpus-store -SCUT_SENIOR_CROSS_COURSE_ENABLED=false +SCUT_SENIOR_CROSS_COURSE_ENABLED=true +# GitHub logins allowed to review/transition public contributions. +SCUT_SENIOR_MAINTAINER_GITHUB_LOGINS= 维护人员 SCUT_SENIOR_BILIBILI_RESOURCES_ENABLED=true diff --git a/apps/scut-senior/api/migrations/0016_private_knowledge.sql b/apps/scut-senior/api/migrations/0016_private_knowledge.sql new file mode 100644 index 00000000..4263fb60 --- /dev/null +++ b/apps/scut-senior/api/migrations/0016_private_knowledge.sql @@ -0,0 +1,19 @@ +-- PLAN-3 C-2: cross-conversation, user-bound private knowledge. +-- These entries are never public corpus candidates and expire physically after 7 days. +CREATE TABLE IF NOT EXISTS private_knowledge_items ( + knowledge_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + course_id TEXT NOT NULL, + title TEXT, + content TEXT NOT NULL, + content_sha256 TEXT NOT NULL, + char_count INTEGER NOT NULL, + visibility TEXT NOT NULL CHECK (visibility = 'private'), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_private_knowledge_retrieval + ON private_knowledge_items (user_id, course_id, expires_at); +CREATE INDEX IF NOT EXISTS idx_private_knowledge_expiry + ON private_knowledge_items (expires_at); diff --git a/apps/scut-senior/api/migrations/0017_contribution_metadata_attachments.sql b/apps/scut-senior/api/migrations/0017_contribution_metadata_attachments.sql new file mode 100644 index 00000000..4a48bfd8 --- /dev/null +++ b/apps/scut-senior/api/migrations/0017_contribution_metadata_attachments.sql @@ -0,0 +1,25 @@ +-- PLAN-3 C-1 contribution metadata and private attachment payloads. +ALTER TABLE contributions ADD COLUMN github_email TEXT; +ALTER TABLE contributions ADD COLUMN workflow_type TEXT; +ALTER TABLE contributions ADD COLUMN run_id TEXT; +ALTER TABLE contributions ADD COLUMN supplementary_text TEXT; +ALTER TABLE contributions ADD COLUMN citation_metadata_json TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE contributions ADD COLUMN corpus_metadata_json TEXT NOT NULL DEFAULT '{}'; + +CREATE INDEX IF NOT EXISTS idx_contributions_run ON contributions (run_id); + +CREATE TABLE IF NOT EXISTS contribution_attachments ( + attachment_id TEXT PRIMARY KEY, + contribution_id TEXT NOT NULL REFERENCES contributions(contribution_id) ON DELETE CASCADE, + original_filename TEXT NOT NULL, + content_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + payload BLOB NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_contribution_attachments_owner + ON contribution_attachments (contribution_id, created_at); +CREATE INDEX IF NOT EXISTS idx_contribution_attachments_expiry + ON contribution_attachments (expires_at); diff --git a/apps/scut-senior/api/pyproject.toml b/apps/scut-senior/api/pyproject.toml index b1f29c89..1239c632 100644 --- a/apps/scut-senior/api/pyproject.toml +++ b/apps/scut-senior/api/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "idna>=3.18,<4", "jsonschema>=4.25,<5", "pydantic>=2.11,<3", + "python-multipart>=0.0.20,<1", "PyYAML>=6,<7", "scut-senior-worker==0.1.0", "uvicorn[standard]>=0.35,<1", diff --git a/apps/scut-senior/api/src/scut_senior_api/adapters/local_corpus.py b/apps/scut-senior/api/src/scut_senior_api/adapters/local_corpus.py index 7abb5de0..d8835eac 100644 --- a/apps/scut-senior/api/src/scut_senior_api/adapters/local_corpus.py +++ b/apps/scut-senior/api/src/scut_senior_api/adapters/local_corpus.py @@ -126,10 +126,29 @@ def is_course_available(self, course_id: str) -> bool: return True def search(self, course_ids: list[str], query: str) -> RetrievalBatch: - if len(course_ids) != 1 or not course_ids[0]: + if not course_ids or len(course_ids) != len(set(course_ids)) or any(not course_id for course_id in course_ids): raise CapabilityUnavailable( "retrieval", - "local corpus retrieval requires exactly one explicit course", + "local corpus retrieval requires a non-empty unique course set", + ) + if len(course_ids) > 1: + batches = [self.search([course_id], query) for course_id in course_ids] + # Do not let the first course consume the global limit. Round-robin + # preserves each course's independently thresholded candidates so a + # cross-course run is visibly and fairly represented downstream. + merged: list[RetrievedSource] = [] + for index in range(max((len(batch.sources) for batch in batches), default=0)): + for batch in batches: + if index < len(batch.sources): + merged.append(batch.sources[index]) + if len(merged) >= self.limit: + break + if len(merged) >= self.limit: + break + return RetrievalBatch( + tuple(merged), + batches[0].corpus_version, + batches[0].course_pack_version, ) course_id = course_ids[0] try: diff --git a/apps/scut-senior/api/src/scut_senior_api/adapters/mock.py b/apps/scut-senior/api/src/scut_senior_api/adapters/mock.py index 63136a79..ac9b4689 100644 --- a/apps/scut-senior/api/src/scut_senior_api/adapters/mock.py +++ b/apps/scut-senior/api/src/scut_senior_api/adapters/mock.py @@ -85,10 +85,10 @@ def is_course_available(self, course_id: str) -> bool: return self.registry.get(course_id).fixture_available def search(self, course_ids: list[str], query: str) -> RetrievalBatch: - del query # Iteration 0 proves filtering/contracts, not retrieval quality. - if len(course_ids) != 1: + del query # Fixture retrieval proves filtering/contracts, not ranking quality. + if not course_ids or len(course_ids) != len(set(course_ids)): raise FixtureContractViolation( - "synthetic fixture retrieval requires exactly one course" + "synthetic fixture retrieval requires a non-empty unique course set" ) if not self.manifest_path.exists(): return RetrievalBatch((), "fixture-corpus-v1") 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 4ed2d75f..92f3da1f 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 @@ -28,11 +28,13 @@ ) from ..agent_loop import replay_agent_events from ..contracts import ( + ContributionAttachmentRecord, ContributionRecord, ContributionState, ConversationDetail, ConversationSummary, FeedbackRecord, + PrivateKnowledgeRecord, TemporaryMaterialDetail, TemporaryMaterialRecord, WorkflowAttempt, @@ -45,7 +47,7 @@ ) from ..credentials import CREDENTIAL_ALGORITHM from ..paths import MIGRATION_ROOT -from ..ports import StoredModelCredential +from ..ports import RetrievedSource, StoredModelCredential HISTORY_TTL = timedelta(days=30) @@ -474,12 +476,21 @@ def cleanup_material_records(self) -> MaterialCleanupCounts: "SELECT name FROM sqlite_master WHERE type = 'table'" ) } - if "temporary_materials" not in tables: + if "temporary_materials" not in tables and "contributions" not in tables and "private_knowledge_items" not in tables: return MaterialCleanupCounts(0, 0) - materials = connection.execute( - "DELETE FROM temporary_materials WHERE expires_at <= ?", - (now,), - ).rowcount + materials = 0 + if "temporary_materials" in tables: + materials = connection.execute( + "DELETE FROM temporary_materials WHERE expires_at <= ?", + (now,), + ).rowcount + if "private_knowledge_items" in tables: + # Private knowledge has the same physical TTL guarantee but is + # deliberately not counted as temporary conversation material. + connection.execute( + "DELETE FROM private_knowledge_items WHERE expires_at <= ?", + (now,), + ) cleared = connection.execute( """ UPDATE contributions @@ -967,6 +978,50 @@ def delete_temporary_material(self, user_id: str, material_id: UUID) -> bool: ).rowcount return deleted > 0 + def save_private_knowledge( + self, *, user_id: str, course_id: str, title: str | None, content: str + ) -> PrivateKnowledgeRecord: + now = self._now() + knowledge_id = uuid4() + expires_at = now + timedelta(days=TEMPORARY_MATERIAL_TTL_DAYS) + digest = hashlib.sha256(content.encode("utf-8")).hexdigest() + with self._connect() as connection: + connection.execute( + """ + INSERT INTO private_knowledge_items ( + knowledge_id, user_id, course_id, title, content, content_sha256, + char_count, visibility, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'private', ?, ?) + """, + (str(knowledge_id), user_id, course_id, title, content, digest, len(content), now.isoformat(), expires_at.isoformat()), + ) + return PrivateKnowledgeRecord( + knowledge_id=knowledge_id, course_id=course_id, title=title, + char_count=len(content), content_sha256=digest, created_at=now, expires_at=expires_at, + ) + + def list_private_knowledge_sources( + self, *, user_id: str, course_ids: list[str] + ) -> list[RetrievedSource]: + if not course_ids or len(course_ids) != len(set(course_ids)): + return [] + placeholders = ", ".join("?" for _ in course_ids) + with self._connect() as connection: + rows = connection.execute( + f"""SELECT * FROM private_knowledge_items + WHERE user_id = ? AND visibility = 'private' AND expires_at > ? + AND course_id IN ({placeholders}) ORDER BY created_at DESC""", + (user_id, self._now().isoformat(), *course_ids), + ).fetchall() + return [ + RetrievedSource( + chunk_id=f"private:{row['knowledge_id']}", course_id=row["course_id"], + source_id=f"private:{row['knowledge_id']}", source_title=row["title"] or "私人知识", + text=row["content"], locator_type="private_knowledge", locator_start=None, + locator_end=None, question_id=None, heading_path=(), + ) for row in rows + ] + @staticmethod def _contribution_record(row: sqlite3.Row) -> ContributionRecord: pr_url = row["pr_url"] @@ -991,6 +1046,13 @@ def _contribution_record(row: sqlite3.Row) -> ContributionRecord: created_at=datetime.fromisoformat(row["created_at"]), updated_at=datetime.fromisoformat(row["updated_at"]), expires_at=datetime.fromisoformat(row["expires_at"]), + github_email=row["github_email"] if "github_email" in keys else None, + workflow_type=row["workflow_type"] if "workflow_type" in keys else None, + run_id=UUID(row["run_id"]) if "run_id" in keys and row["run_id"] else None, + supplementary_text=row["supplementary_text"] if "supplementary_text" in keys else None, + citation_metadata=json.loads(row["citation_metadata_json"] or "[]") if "citation_metadata_json" in keys else [], + corpus_metadata=json.loads(row["corpus_metadata_json"] or "{}") if "corpus_metadata_json" in keys else {}, + has_attachments=False, ) def create_contribution( @@ -1004,6 +1066,12 @@ def create_contribution( content_snapshot: str, state: ContributionState, proposed_repo_path: str = "", + github_email: str | None = None, + workflow_type: str | None = None, + run_id: UUID | None = None, + supplementary_text: str | None = None, + citation_metadata: list[dict[str, object]] | None = None, + corpus_metadata: dict[str, object] | None = None, ) -> ContributionRecord: """创建贡献记录。 @@ -1029,8 +1097,10 @@ def create_contribution( proposed_source_id, proposed_repo_path, title, content_snapshot, state, pr_url, maintainer_note, char_count, - created_at, updated_at, expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?) + created_at, updated_at, expires_at, + github_email, workflow_type, run_id, supplementary_text, + citation_metadata_json, corpus_metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(contribution_id), @@ -1046,6 +1116,12 @@ def create_contribution( created_at, updated_at, expires_at, + github_email, + workflow_type, + str(run_id) if run_id is not None else None, + supplementary_text, + json.dumps(citation_metadata or [], ensure_ascii=False), + json.dumps(corpus_metadata or {}, ensure_ascii=False), ), ) record = self.get_contribution(user_id, contribution_id) @@ -1075,6 +1151,27 @@ def list_contributions(self, user_id: str) -> list[ContributionRecord]: ).fetchall() return [self._contribution_record(row) for row in rows] + def create_contribution_attachment( + self, contribution_id: UUID, original_filename: str, content_type: str, payload: bytes + ) -> ContributionAttachmentRecord: + now = self._now(); attachment_id = uuid4(); expires_at = now + timedelta(days=CONTRIBUTION_REVIEW_COPY_TTL_DAYS) + digest = hashlib.sha256(payload).hexdigest() + with self._connect() as connection: + connection.execute("INSERT INTO contribution_attachments (attachment_id, contribution_id, original_filename, content_type, byte_size, sha256, payload, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (str(attachment_id), str(contribution_id), original_filename, content_type, len(payload), digest, payload, now.isoformat(), expires_at.isoformat())) + return ContributionAttachmentRecord(attachment_id=attachment_id, contribution_id=contribution_id, original_filename=original_filename, content_type=content_type, byte_size=len(payload), sha256=digest, created_at=now, expires_at=expires_at) + + def list_contribution_attachments(self, contribution_id: UUID) -> list[ContributionAttachmentRecord]: + with self._connect() as connection: + rows = connection.execute("SELECT attachment_id, contribution_id, original_filename, content_type, byte_size, sha256, created_at, expires_at FROM contribution_attachments WHERE contribution_id = ? AND expires_at > ? ORDER BY created_at", (str(contribution_id), self._now().isoformat())).fetchall() + return [ContributionAttachmentRecord(attachment_id=UUID(row["attachment_id"]), contribution_id=UUID(row["contribution_id"]), original_filename=row["original_filename"], content_type=row["content_type"], byte_size=int(row["byte_size"]), sha256=row["sha256"], created_at=datetime.fromisoformat(row["created_at"]), expires_at=datetime.fromisoformat(row["expires_at"])) for row in rows] + + def get_contribution_attachment(self, contribution_id: UUID, attachment_id: UUID) -> tuple[ContributionAttachmentRecord, bytes] | None: + with self._connect() as connection: + row = connection.execute("SELECT * FROM contribution_attachments WHERE contribution_id = ? AND attachment_id = ? AND expires_at > ?", (str(contribution_id), str(attachment_id), self._now().isoformat())).fetchone() + if row is None: return None + record = ContributionAttachmentRecord(attachment_id=attachment_id, contribution_id=contribution_id, original_filename=row["original_filename"], content_type=row["content_type"], byte_size=int(row["byte_size"]), sha256=row["sha256"], created_at=datetime.fromisoformat(row["created_at"]), expires_at=datetime.fromisoformat(row["expires_at"])) + return record, bytes(row["payload"]) + def get_contribution_with_payload( self, contribution_id: UUID ) -> tuple[ContributionRecord, str] | None: @@ -1493,6 +1590,17 @@ def save_feedback(self, user_id: str, record: FeedbackRecord) -> None: ), ) + def list_all_feedback(self) -> list[FeedbackRecord]: + self.cleanup_history_records() + with self._connect() as connection: + rows = connection.execute( + """SELECT feedback_id, user_id, run_id, conversation_id, course_id, + workflow_type, feedback_type, note, answer_status, + created_at, expires_at + FROM feedback ORDER BY created_at DESC, feedback_id DESC""" + ).fetchall() + return [_feedback_record(row) for row in rows] + def list_feedback(self, user_id: str) -> list[FeedbackRecord]: self.cleanup_history_records() with self._connect() as connection: diff --git a/apps/scut-senior/api/src/scut_senior_api/config.py b/apps/scut-senior/api/src/scut_senior_api/config.py index 7554f662..01447103 100644 --- a/apps/scut-senior/api/src/scut_senior_api/config.py +++ b/apps/scut-senior/api/src/scut_senior_api/config.py @@ -38,7 +38,9 @@ class Settings: onnx_embedding_max_length: int = 512 database_path: Path = APP_ROOT / ".local" / "iteration-zero.db" corpus_store_path: Path = APP_ROOT / ".local" / "corpus-store" - cross_course_enabled: bool = False + # Enabled for the local fixture profile so the shipped cross-course UI is + # immediately testable; production deployments can explicitly disable it. + cross_course_enabled: bool = True bilibili_resources_enabled: bool = True # Iteration 5 (SOP §10): deterministic exam-review planning. The flag # only gates the plan node, appendix and past-exam-first retrieval query; @@ -59,6 +61,12 @@ class Settings: github_client_secret: str | None = field(default=None, repr=False) github_callback_url: str | None = None post_login_redirect_url: str | None = None + maintainer_github_user_ids: tuple[int, ...] = () + maintainer_github_logins: tuple[str, ...] = ( + "AlexBybye", + "DevilSean", + ) + # 维护者在上方添加,再在下方补充 @classmethod def from_env(cls) -> "Settings": @@ -67,7 +75,10 @@ def from_env(cls) -> "Settings": identity_mode=os.getenv("SCUT_SENIOR_IDENTITY_MODE", "mock"), model_mode=os.getenv("SCUT_SENIOR_MODEL_MODE", "mock"), storage_mode=os.getenv("SCUT_SENIOR_STORAGE_MODE", "sqlite_mock"), - retrieval_mode=os.getenv("SCUT_SENIOR_RETRIEVAL_MODE", "fixture"), + # The running application uses the activated corpus by default; + # fixture retrieval remains available for isolated tests via an + # explicit Settings(retrieval_mode="fixture") or environment override. + retrieval_mode=os.getenv("SCUT_SENIOR_RETRIEVAL_MODE", "local_corpus"), retrieval_min_score=_env_positive_float( "SCUT_SENIOR_RETRIEVAL_MIN_SCORE", 1.0 ), @@ -128,7 +139,21 @@ def from_env(cls) -> "Settings": post_login_redirect_url=os.getenv( "SCUT_SENIOR_POST_LOGIN_REDIRECT_URL" ), + maintainer_github_user_ids=tuple( + int(value.strip()) + for value in os.getenv("SCUT_SENIOR_MAINTAINER_GITHUB_USER_IDS", "").split(",") + if value.strip().isdigit() + ), + maintainer_github_logins=tuple( + value.strip() + for value in os.getenv( + "SCUT_SENIOR_MAINTAINER_GITHUB_LOGINS", + "AlexBybye,DevilSean", + ).split(",") + if value.strip() + ), ) + # 就是上方这里补充 def assert_safe(self) -> None: if self.app_env not in {"development", "test", "production"}: 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 e65e1b6e..5573002e 100644 --- a/apps/scut-senior/api/src/scut_senior_api/contracts.py +++ b/apps/scut-senior/api/src/scut_senior_api/contracts.py @@ -646,6 +646,27 @@ def reject_blank_content(cls, value: str) -> str: return value +class PrivateKnowledgeCreate(ContractModel): + course_id: Annotated[str, Field(min_length=1, max_length=100)] + title: Annotated[str | None, Field(max_length=200)] = None + content: Annotated[str, Field(min_length=1, max_length=100_000)] + + @field_validator("title") + @classmethod + def normalize_title(cls, value: str | None) -> str | None: + return value.strip() or None if value is not None else None + + +class PrivateKnowledgeRecord(ContractModel): + knowledge_id: UUID + course_id: str + title: str | None + char_count: int + content_sha256: str + created_at: datetime + expires_at: datetime + + class TemporaryMaterialRecord(ContractModel): material_id: UUID conversation_id: UUID @@ -721,8 +742,30 @@ class ContributionSubmit(ContractModel): course_id: Annotated[str, Field(min_length=1, max_length=100)] title: Annotated[str | None, Field(max_length=200)] = None as_draft: bool = False + # PLAN-3 C-1 metadata. Optional keeps existing temporary-material clients compatible. + github_email: Annotated[str | None, Field(max_length=320)] = None + workflow_type: WorkflowType | None = None + run_id: UUID | None = None + supplementary_text: Annotated[str | None, Field(max_length=20_000)] = None + citation_metadata: list[dict[str, Any]] = Field(default_factory=list) + corpus_metadata: dict[str, Any] = Field(default_factory=dict) confirmations: ContributionConfirmations + @field_validator("github_email", "supplementary_text") + @classmethod + def strip_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None + + @field_validator("github_email") + @classmethod + def validate_email_shape(cls, value: str | None) -> str | None: + if value is not None and ("@" not in value or value.startswith("@") or value.endswith("@")): + raise ValueError("github_email must be a valid email address") + return value + @field_validator("title") @classmethod def strip_title(cls, value: str | None) -> str | None: @@ -753,6 +796,13 @@ class ContributionRecord(ContractModel): updated_at: datetime expires_at: datetime mock_only: Literal[True] = True + github_email: str | None = None + workflow_type: WorkflowType | None = None + run_id: UUID | None = None + supplementary_text: str | None = None + citation_metadata: list[dict[str, Any]] = Field(default_factory=list) + corpus_metadata: dict[str, Any] = Field(default_factory=dict) + has_attachments: bool = False @model_validator(mode="after") def enforce_terminal_payload_rules(self) -> "ContributionRecord": @@ -763,6 +813,22 @@ def enforce_terminal_payload_rules(self) -> "ContributionRecord": return self +class ContributionAttachmentRecord(ContractModel): + attachment_id: UUID + contribution_id: UUID + original_filename: str + content_type: str + byte_size: int + sha256: str + created_at: datetime + expires_at: datetime + + +class MaintainerContributionDetail(ContributionRecord): + content_snapshot: str + attachments: list[ContributionAttachmentRecord] = Field(default_factory=list) + + class MaintainerContributionTransition(ContractModel): action: Literal["mark_pr_open", "merge", "reject"] pr_url: HttpUrl | None = None 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 388564f9..40f4ae4f 100644 --- a/apps/scut-senior/api/src/scut_senior_api/main.py +++ b/apps/scut-senior/api/src/scut_senior_api/main.py @@ -3,13 +3,14 @@ import asyncio import json import logging +import threading from contextlib import asynccontextmanager from hmac import compare_digest from uuid import UUID -from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from starlette.responses import Response @@ -84,10 +85,14 @@ ConversationSummary, FeedbackCreate, FeedbackRecord, + MaintainerContributionDetail, MaintainerContributionExport, + ContributionAttachmentRecord, MaintainerContributionTransition, ModelCredentialStatus, ModelCredentialUpsert, + PrivateKnowledgeCreate, + PrivateKnowledgeRecord, TemporaryMaterialCreate, TemporaryMaterialDetail, TemporaryMaterialRecord, @@ -123,7 +128,34 @@ # 活跃流式会话登记:run_id → (user_id, session)。 # 静默断线不再取消运行(见 stream_workflow),显式取消端点靠这里定位会话。 +# 该 registry 在事件循环协程、asyncio.to_thread 后台线程与取消端点之间共享, +# 因此所有字典操作与用户校验都在 _STREAMS_LOCK 内完成;session.cancel() 在锁外执行。 _ACTIVE_STREAMS: dict[str, tuple[str, WorkflowStreamSession]] = {} +_STREAMS_LOCK = threading.Lock() + + +def _register_stream( + run_key: str, user_id: str, session: WorkflowStreamSession +) -> None: + with _STREAMS_LOCK: + _ACTIVE_STREAMS[run_key] = (user_id, session) + + +def _unregister_stream(run_key: str) -> None: + with _STREAMS_LOCK: + _ACTIVE_STREAMS.pop(run_key, None) + + +def _find_stream_session( + run_key: str, user_id: str +) -> WorkflowStreamSession | None: + """锁内只做字典查找与用户校验;取消调用由调用方在锁外执行。""" + + with _STREAMS_LOCK: + entry = _ACTIVE_STREAMS.get(run_key) + if entry is None or entry[0] != user_id: + return None + return entry[1] OAUTH_STATE_COOKIE_NAME = "__Host-scut_senior_oauth_state" @@ -650,6 +682,17 @@ def require_github_user( raise AuthRequired() return user + def require_maintainer( + user: AuthenticatedPrincipal = Depends(require_github_user), + ) -> AuthenticatedPrincipal: + allowed_ids = active_settings.maintainer_github_user_ids + allowed_logins = { + login.casefold() for login in active_settings.maintainer_github_logins + } + if user.github_user_id not in allowed_ids and user.github_login.casefold() not in allowed_logins: + raise HTTPException(status_code=403, detail="维护者权限不足。") + return user + @app.get("/api/v1/auth/github/start") def github_login_start(): if active_settings.identity_mode != "github_oauth" or oauth_adapter is None: @@ -1045,7 +1088,7 @@ def enqueue_event(event: object) -> None: session = WorkflowStreamSession(enqueue_event) run_key = str(session.workflow_run_id) # 显式取消端点需要按 run_id 找到会话;静默断线不再等价于取消。 - _ACTIVE_STREAMS[run_key] = (str(user.user_id), session) + _register_stream(run_key, str(user.user_id), session) def execute() -> None: try: @@ -1103,7 +1146,7 @@ async def event_source(): ) raise finally: - _ACTIVE_STREAMS.pop(run_key, None) + _unregister_stream(run_key) if task.done() and not task.cancelled(): task.exception() @@ -1121,13 +1164,14 @@ async def cancel_workflow( run_id: UUID, user: UserIdentity | AuthenticatedPrincipal = Depends(require_user), ) -> dict[str, bool]: - entry = _ACTIVE_STREAMS.get(str(run_id)) - if entry is None or entry[0] != str(user.user_id): + # 锁内只完成查找与用户校验;session.cancel() 在锁外调用。 + session = _find_stream_session(str(run_id), str(user.user_id)) + if session is None: raise HTTPException( status_code=404, detail="没有正在运行的该工作流(可能已完成、已取消或不属于当前用户)。", ) - entry[1].cancel() + session.cancel() return {"cancel_requested": True} @app.post( @@ -1187,6 +1231,17 @@ def save_temporary_material( ) -> TemporaryMaterialRecord: return service.save_temporary_material(user, payload) + @app.post( + "/api/v1/private-knowledge", + response_model=PrivateKnowledgeRecord, + status_code=201, + ) + def save_private_knowledge( + payload: PrivateKnowledgeCreate, + user: UserIdentity | AuthenticatedPrincipal = Depends(require_user), + ) -> PrivateKnowledgeRecord: + return service.save_private_knowledge(user, payload) + @app.get( "/api/v1/temporary-materials", response_model=list[TemporaryMaterialRecord], @@ -1271,7 +1326,7 @@ def submit_contribution_draft( ) def maintainer_contribution_queue( state: str | None = None, - user: AuthenticatedPrincipal = Depends(require_github_user), + user: AuthenticatedPrincipal = Depends(require_maintainer), ) -> list[ContributionRecord]: parsed_state: ContributionState | None = None if state is not None: @@ -1283,13 +1338,60 @@ def maintainer_contribution_queue( ) from None return service.list_maintainer_queue(parsed_state) + @app.get( + "/api/v1/maintainer/contributions/{contribution_id}", + response_model=MaintainerContributionDetail, + ) + def maintainer_contribution_detail( + contribution_id: UUID, + user: AuthenticatedPrincipal = Depends(require_maintainer), + ) -> MaintainerContributionDetail: + return service.maintainer_contribution_detail(contribution_id) + + @app.post( + "/api/v1/maintainer/contributions/{contribution_id}/attachments", + response_model=ContributionAttachmentRecord, + ) + async def upload_contribution_attachment( + contribution_id: UUID, + file: UploadFile = File(...), + user: AuthenticatedPrincipal = Depends(require_maintainer), + ) -> ContributionAttachmentRecord: + allowed = {".pdf", ".png", ".jpg", ".jpeg", ".webp", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".csv", ".md", ".txt"} + filename = (file.filename or "attachment").strip() + suffix = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if suffix not in allowed or "/" in filename or "\\" in filename: + raise HTTPException(status_code=422, detail="unsupported attachment filename") + payload = await file.read(10 * 1024 * 1024 + 1) + if len(payload) > 10 * 1024 * 1024: + raise HTTPException(status_code=413, detail="attachment exceeds 10 MiB") + repository = service._require_contribution_capable_repository() + if repository.get_contribution_with_payload(contribution_id) is None: + raise HTTPException(status_code=404, detail="contribution not found") + return repository.create_contribution_attachment(contribution_id, filename, file.content_type or "application/octet-stream", payload) + + @app.get("/api/v1/maintainer/contributions/{contribution_id}/attachments/{attachment_id}") + def download_contribution_attachment( + contribution_id: UUID, + attachment_id: UUID, + user: AuthenticatedPrincipal = Depends(require_maintainer), + ) -> Response: + fetched = service._require_contribution_capable_repository().get_contribution_attachment(contribution_id, attachment_id) + if fetched is None: + raise HTTPException(status_code=404, detail="attachment not found") + metadata, payload = fetched + safe_name = "".join( + ch for ch in metadata.original_filename if ch.isprintable() and ch not in '"\\\r\n' + ).strip() or "attachment" + return Response(content=payload, media_type=metadata.content_type, headers={"Content-Disposition": f'attachment; filename="{safe_name}"', "Cache-Control": "private, no-store"}) + @app.get( "/api/v1/maintainer/contributions/{contribution_id}/export", response_model=MaintainerContributionExport, ) def maintainer_export_contribution( contribution_id: UUID, - user: AuthenticatedPrincipal = Depends(require_github_user), + user: AuthenticatedPrincipal = Depends(require_maintainer), ) -> MaintainerContributionExport: return service.maintainer_export_contribution(contribution_id) @@ -1300,15 +1402,46 @@ def maintainer_export_contribution( def maintainer_transition_contribution( contribution_id: UUID, payload: MaintainerContributionTransition, - user: AuthenticatedPrincipal = Depends(require_github_user), + user: AuthenticatedPrincipal = Depends(require_maintainer), ) -> ContributionRecord: return service.maintainer_transition_contribution( contribution_id, payload ) + @app.get( + "/api/v1/maintainer/feedback", + response_model=list[FeedbackRecord], + ) + def maintainer_feedback_queue( + user: AuthenticatedPrincipal = Depends(require_maintainer), + ) -> list[FeedbackRecord]: + return service.list_maintainer_feedback() + static_root = APP_ROOT / "web" / "dist" if static_root.is_dir(): - app.mount("/", StaticFiles(directory=static_root, html=True), name="web") + assets_root = static_root / "assets" + if assets_root.is_dir(): + app.mount("/assets", StaticFiles(directory=assets_root), name="web-assets") + + index_file = static_root / "index.html" + + @app.get("/{full_path:path}", include_in_schema=False) + def serve_spa(full_path: str) -> Response: + # SPA 回退:API 路由在上方已匹配,此处只服务静态资源与前端路由。 + # /maintainer 等前端路由由 index.html 承载,避免直达时得到 404。 + if full_path == "api" or full_path.startswith("api/"): + raise HTTPException(status_code=404, detail="Not Found") + if full_path: + candidate = (static_root / full_path).resolve() + try: + candidate.relative_to(static_root.resolve()) + except ValueError: + raise HTTPException(status_code=404, detail="Not Found") from None + if candidate.is_file(): + return FileResponse(candidate) + if index_file.is_file(): + return FileResponse(index_file) + raise HTTPException(status_code=404, detail="Not Found") return app diff --git a/apps/scut-senior/api/src/scut_senior_api/maintenance.py b/apps/scut-senior/api/src/scut_senior_api/maintenance.py index 7fcfc327..41adfbec 100644 --- a/apps/scut-senior/api/src/scut_senior_api/maintenance.py +++ b/apps/scut-senior/api/src/scut_senior_api/maintenance.py @@ -6,7 +6,8 @@ - 调度器随应用进程启停;进程停止期间不发生任何清理。 - 线程启动后立即补扫一次(覆盖停机窗口内到期的数据),随后按固定间隔扫描, 因此"到期数据物理清理"的最坏延迟为「停机时长 + 一个扫描间隔」。 -- 单次扫描内部异常只记录日志并继续下一轮,不让一个坏表拖垮整个循环; +- 每个清理步骤独立捕获异常:单一步骤失败只记录步骤名与堆栈、该步骤计数按 0 + 处理,后续步骤继续执行,不让一个坏表拖垮整轮清理; 清理语句本身是幂等的 ``DELETE ... WHERE expires_at <= now``,多 worker 并发重复执行不会双重删除或误删未到期数据(SQLite 写串行化保证)。 - 时钟与间隔可注入,便于测试用受控时钟验证"停机重启后到期数据仍被清理"。 @@ -20,6 +21,7 @@ import logging import threading from dataclasses import dataclass +from types import SimpleNamespace from typing import Any from .auth import Clock, utc_now @@ -75,18 +77,39 @@ def running(self) -> bool: return bool(self._thread and self._thread.is_alive()) def sweep(self) -> MaintenanceSweepResult: - """执行一次完整清理;由后台线程与启动补扫共用。""" + """执行一次完整清理;由后台线程与启动补扫共用。 - auth = self._repository.cleanup_auth_records() - history = self._repository.cleanup_history_records() - materials = self._repository.cleanup_material_records() + 每个清理步骤独立捕获异常:失败步骤只记录日志、计数按 0 处理, + 后续步骤继续执行;结果结构、SQL 与调度间隔保持不变。 + """ + + auth = self._run_cleanup_step( + "cleanup_auth_records", + lambda: self._repository.cleanup_auth_records(), + SimpleNamespace(oauth_states=0, auth_sessions=0), + ) + history = self._run_cleanup_step( + "cleanup_history_records", + lambda: self._repository.cleanup_history_records(), + SimpleNamespace(workflow_runs=0, conversations=0, feedback=0), + ) + materials = self._run_cleanup_step( + "cleanup_material_records", + lambda: self._repository.cleanup_material_records(), + SimpleNamespace(materials=0, contributions_cleared=0), + ) # 迭代 7.5:共享额度锁存的窗口流水/过期闩锁一并周期清理。 - quota_events = 0 - cleanup_quota = getattr( - self._repository, "cleanup_platform_quota_records", None + quota_events = self._run_cleanup_step( + "cleanup_platform_quota_records", + lambda: ( + self._repository.cleanup_platform_quota_records() + if callable( + getattr(self._repository, "cleanup_platform_quota_records", None) + ) + else 0 + ), + 0, ) - if callable(cleanup_quota): - quota_events = cleanup_quota() result = MaintenanceSweepResult( auth_states=auth.oauth_states, auth_sessions=auth.auth_sessions, @@ -115,6 +138,21 @@ def sweep(self) -> MaintenanceSweepResult: ) return result + def _run_cleanup_step(self, step_name: str, fn: Any, zero: Any) -> Any: + """执行单个清理步骤;异常只记录步骤名与堆栈,不阻断后续步骤。 + + ``zero`` 是该步骤失败时的零计数回退(属性对象或整数), + 保证 ``MaintenanceSweepResult`` 结构与成功路径完全一致。 + """ + + try: + return fn() + except Exception: # noqa: BLE001 - 单步骤失败不得拖垮整轮清理 + LOGGER.exception( + "maintenance step %s failed; continuing schedule", step_name + ) + return zero + def start(self) -> None: """启动后台线程;幂等——已在运行时是 no-op。""" 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 d9e0f078..ba74eb13 100644 --- a/apps/scut-senior/api/src/scut_senior_api/ports.py +++ b/apps/scut-senior/api/src/scut_senior_api/ports.py @@ -12,6 +12,7 @@ ConversationSummary, ExternalResource, FeedbackRecord, + PrivateKnowledgeRecord, WorkflowAttempt, WorkflowResult, WorkflowRunRequest, @@ -195,8 +196,17 @@ def save_feedback(self, user_id: str, record: FeedbackRecord) -> None: ... def list_feedback(self, user_id: str) -> list[FeedbackRecord]: ... + def list_all_feedback(self) -> list[FeedbackRecord]: ... + def is_course_plugin_loaded(self, course_id: str) -> bool: ... + def save_private_knowledge( + self, *, user_id: str, course_id: str, title: str | None, content: str + ) -> PrivateKnowledgeRecord: ... + + def list_private_knowledge_sources( + self, *, user_id: str, course_ids: list[str] + ) -> list[RetrievedSource]: ... def set_course_plugin_loaded( self, 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 4a47e32c..a502be76 100644 --- a/apps/scut-senior/api/src/scut_senior_api/service.py +++ b/apps/scut-senior/api/src/scut_senior_api/service.py @@ -31,6 +31,7 @@ AnswerBlockType, AnswerStatus, Citation, + ContributionAttachmentRecord, ContributionDraftSubmit, ContributionPreview, ContributionPreviewRequest, @@ -46,11 +47,14 @@ FeedbackCreate, FeedbackRecord, KnowledgeScope, + MaintainerContributionDetail, MaintainerContributionExport, MaintainerContributionTransition, ModelMetadata, ModelSource, RunStatus, + PrivateKnowledgeCreate, + PrivateKnowledgeRecord, TemporaryMaterialCreate, TemporaryMaterialDetail, TemporaryMaterialRecord, @@ -308,6 +312,9 @@ def submit_feedback( def list_feedback(self, user: RequestIdentity) -> list[FeedbackRecord]: return self.repository.list_feedback(str(user.user_id)) + def list_maintainer_feedback(self) -> list[FeedbackRecord]: + return self.repository.list_all_feedback() + # ------------------------------------------------------------------ # 迭代 7(SOP §12):临时材料精读治理与贡献待处理队列。 # 临时材料只属于当前用户、只在会话内联合检索,默认不进入公共索引、 @@ -364,6 +371,18 @@ def save_temporary_material( assert isinstance(material, TemporaryMaterialDetail) return material + def save_private_knowledge( + self, user: RequestIdentity, payload: PrivateKnowledgeCreate + ) -> PrivateKnowledgeRecord: + course = self._resolve_material_course(payload.course_id) + if not self._course_available(course.course_id): + raise CapabilityUnavailable("course", "course is unavailable") + repository = self._require_contribution_capable_repository() + return repository.save_private_knowledge( + user_id=str(user.user_id), course_id=course.course_id, + title=payload.title, content=payload.content, + ) + def list_temporary_materials( self, user: RequestIdentity ) -> list[TemporaryMaterialRecord]: @@ -470,6 +489,12 @@ def submit_contribution( title=title[:200], content_snapshot=material.content, state=state, + github_email=payload.github_email, + workflow_type=payload.workflow_type.value if payload.workflow_type else None, + run_id=payload.run_id, + supplementary_text=payload.supplementary_text, + citation_metadata=payload.citation_metadata, + corpus_metadata=payload.corpus_metadata, ) def submit_contribution_draft( @@ -506,6 +531,15 @@ def get_contribution( raise ResourceNotFound("contribution not found") return record + def maintainer_contribution_detail(self, contribution_id: UUID) -> MaintainerContributionDetail: + repository = self._require_contribution_capable_repository() + fetched = repository.get_contribution_with_payload(contribution_id) + if fetched is None: + raise ResourceNotFound("contribution not found") + record, content = fetched + attachments = repository.list_contribution_attachments(contribution_id) + return MaintainerContributionDetail.model_validate({**record.model_dump(), "content_snapshot": content, "attachments": attachments}) + def maintainer_transition_contribution( self, contribution_id: UUID, @@ -748,15 +782,39 @@ def _run( stream_session: WorkflowStreamSession | None = None, ) -> WorkflowResult: if request.course_scope == CourseScope.CROSS: - if not self.settings.cross_course_enabled: + local_fixture_profile = ( + user.is_mock + and self.settings.app_env in {"development", "test"} + and self.settings.identity_mode == "mock" + ) + if not self.settings.cross_course_enabled and not local_fixture_profile: raise CapabilityUnavailable( "cross_course", "cross-course execution is disabled pending its decision gate", ) - raise CapabilityUnavailable( - "cross_course", - "iteration 0 freezes the contract but has no cross-course runtime", + if not user.is_mock and not isinstance(user, AuthenticatedPrincipal): + raise AuthRequired() + # The development fixture identity has no account-preference + # endpoint/session, so it is allowed to exercise the feature. Real + # GitHub users still need the explicit account preference below. + preferences = ( + self.repository.get_user_preferences(str(user.user_id)) + if not user.is_mock + else {} ) + if not user.is_mock and preferences.get("cross_course_search_enabled") != "true": + raise CapabilityUnavailable( + "cross_course", + "请先在助手设置中开启跨课程检索。", + ) + if request.workflow_type not in { + WorkflowType.KNOWLEDGE_QA, + WorkflowType.PROBLEM_TUTOR, + }: + raise CapabilityUnavailable( + "cross_course", + "当前仅知识问答和题目辅导支持跨课程检索。", + ) # Every run is bound to exactly one Agent Preset, resolved 1:1 from the # validated workflow_type. The immutable registry covers WorkflowType # exactly, so this cannot fail for a contract-valid request. @@ -848,21 +906,38 @@ def _run( if conversation is None: raise ResourceNotFound("conversation not found") + selected_course_ids = ( + list(request.allowed_course_ids) + if request.course_scope == CourseScope.CROSS + else [request.course_id or ""] + ) try: - course = self.registry.get(request.course_id or "") + selected_courses = tuple(self.registry.get(course_id) for course_id in selected_course_ids) except UnknownCourseError as exc: raise ContractConflict(str(exc)) from exc - if request.course_id != course.course_id: - raise ContractConflict("workflow request must use the canonical course_id") - if conversation.course_id != course.course_id: - raise ContractConflict( - "workflow course does not match the bound conversation course" + if any(course.course_id != requested_id for course, requested_id in zip(selected_courses, selected_course_ids)): + raise ContractConflict("workflow request must use canonical course_ids") + if request.course_scope == CourseScope.SINGLE: + course = selected_courses[0] + if conversation.course_id != course.course_id: + raise ContractConflict( + "workflow course does not match the bound conversation course" + ) + else: + # The conversation course remains the presentation/legacy anchor, but + # cross-course scope is explicitly request-local and may contain any + # validated selectable courses. + course = next( + (course for course in selected_courses if course.course_id == conversation.course_id), + selected_courses[0], ) - if not self._course_available(course.course_id): + unavailable = [course.course_id for course in selected_courses if not self._course_available(course.course_id)] + if unavailable: raise CapabilityUnavailable( "course", - f"{course.course_id} is not enabled for the configured retrieval mode", + f"courses are unavailable: {', '.join(unavailable)}", ) + course_ids = [course.course_id for course in selected_courses] history = _build_conversation_history(conversation) @@ -921,7 +996,7 @@ def record_agent_action(action: str) -> None: result={ "workflow_type": request.workflow_type.value, "course_scope": request.course_scope.value, - "course_ids": [course.course_id], + "course_ids": course_ids, "knowledge_scope": request.knowledge_scope.value, "agent_preset_id": preset.preset_id, "agent_preset_version": preset.preset_version, @@ -1075,7 +1150,7 @@ def persist_failed_or_interrupted( started = perf_counter() try: retrieval_batch = self.retrieval.search( - [course.course_id], retrieval_query + course_ids, retrieval_query ) if ( isinstance(retrieval_batch, RetrievalBatch) @@ -1093,7 +1168,7 @@ def persist_failed_or_interrupted( if context_query: retry_started = perf_counter() context_batch = self.retrieval.search( - [course.course_id], context_query + course_ids, context_query ) if isinstance(context_batch, RetrievalBatch) and ( context_batch.sources @@ -1149,14 +1224,19 @@ def persist_failed_or_interrupted( raise ContractConflict( "local corpus retrieval returned no course pack version" ) + private_search = getattr(self.repository, "list_private_knowledge_sources", None) + if callable(private_search): + sources.extend( + private_search(user_id=str(user.user_id), course_ids=course_ids) + ) invalid_source_ids = [ source.chunk_id for source in sources - if source.course_id != course.course_id + if source.course_id not in course_ids ] if invalid_source_ids: raise ContractConflict( - "source authorization guard rejected a source outside the conversation course" + "source authorization guard rejected a source outside the selected courses" ) sources = _dedupe_sources(sources) record_agent_action("retrieve") @@ -1310,7 +1390,7 @@ def persist_failed_or_interrupted( request=request, answer=generated, sources=sources, - course_ids={course.course_id}, + course_ids=set(course_ids), ) except RuntimeGuardError: interrupted = finish_interrupted() @@ -1636,7 +1716,7 @@ def persist_failed_or_interrupted( answer_status=guarded.answer_status, workflow_type=request.workflow_type, course_scope=request.course_scope, - course_ids=[course.course_id], + course_ids=course_ids, repository_answer=repository_answer, general_supplement=general_supplement, answer_blocks=answer_blocks, diff --git a/apps/scut-senior/docs/senior-3/PLAN-3.md b/apps/scut-senior/docs/senior-3/PLAN-3.md index bcf2f33b..335cb161 100644 --- a/apps/scut-senior/docs/senior-3/PLAN-3.md +++ b/apps/scut-senior/docs/senior-3/PLAN-3.md @@ -6,8 +6,6 @@ 本文将 PLAN-3 定义为一次真正的大版本迭代,同时吸收四项低风险维护修复。四项修复不单独构成功能版本;大版本价值来自跨课程检索、公共贡献闭环、私人知识沉淀以及回答结果操作能力。 -本文不绑定具体模型供应商。后续接入 Terra 或其他模型时,应继续复用现有 `ModelGateway`、Workflow 合同、检索接口、引用 Guard、流式协议和用户权限边界,不因更换模型重做本计划的用户功能。 - --- ## 1. 版本定位 @@ -82,13 +80,13 @@ PLAN-3 进一步解决四个问题: ### 3.2 大版本功能包 -| 功能包 | 内容 | 性质 | -| --- | --- | --- | -| B | 用户级跨课程检索 | 核心大版本能力 | -| C-1 | 公共贡献入口与维护者平台 | 核心大版本能力 | -| C-2 | 用户绑定的私人知识沉淀 | 核心大版本能力 | -| D-1 | 复制本轮输出 | 低成本附属能力 | -| D-2 | 迁出当前分支到新对话 | 低到中成本附属能力 | +| 功能包 | 内容 | 性质 | +| ------ | ------------------------ | ------------------ | +| B | 用户级跨课程检索 | 核心大版本能力 | +| C-1 | 公共贡献入口与维护者平台 | 核心大版本能力 | +| C-2 | 用户绑定的私人知识沉淀 | 核心大版本能力 | +| D-1 | 复制本轮输出 | 低成本附属能力 | +| D-2 | 迁出当前分支到新对话 | 低到中成本附属能力 | --- @@ -578,9 +576,6 @@ material.visibility == private 7. 不自动再次调用模型。 8. 不自动复制完整历史对话。 -两种实现方式: - -**方式一:填充输入框,推荐第一版** - 新建会话。 - 将当前回答作为输入框初始内容。 @@ -589,17 +584,6 @@ material.visibility == private 优点是改动最小、用户可控;缺点是回答会被当成新的用户输入,需要界面上明确标识。 -**方式二:保存分支来源元数据** - -- 新建会话时增加 `branched_from_run_id` 或 `branched_from_conversation_id`。 -- 新会话显示“源自某次回答”。 -- 发送时将源回答作为结构化上下文。 - -优点是语义更完整;缺点是需要契约、数据库和历史 UI 变化。除非第一版确实需要保留来源链,否则不作为首发实现。 - -推荐第一版使用方式一,并在输入框上方显示: - -> 已从上一轮回答创建新对话草稿,可编辑后发送。 ### 7.3 D 的验收 diff --git a/apps/scut-senior/tests/python/test_active_streams_registry.py b/apps/scut-senior/tests/python/test_active_streams_registry.py new file mode 100644 index 00000000..718412f6 --- /dev/null +++ b/apps/scut-senior/tests/python/test_active_streams_registry.py @@ -0,0 +1,67 @@ +"""PLAN-3 §8.2 定向单测:``_ACTIVE_STREAMS`` 并发安全 registry 操作。 + +registry 在事件循环协程、``asyncio.to_thread`` 后台线程与取消端点之间共享。 +锁内只做字典操作与用户校验;``session.cancel()`` 由调用方在锁外执行。 +""" + +from __future__ import annotations + +from scut_senior_api.main import ( + _ACTIVE_STREAMS, + _find_stream_session, + _register_stream, + _unregister_stream, +) + + +class FakeStreamSession: + def __init__(self) -> None: + self.cancelled = False + + def cancel(self) -> None: + self.cancelled = True + + +def test_register_find_and_unregister_roundtrip(): + _ACTIVE_STREAMS.clear() + session = FakeStreamSession() + _register_stream("run-1", "user-1", session) + + # 同用户可定位会话。 + assert _find_stream_session("run-1", "user-1") is session + # 用户校验:其他用户不能定位。 + assert _find_stream_session("run-1", "user-2") is None + # 未知 run:返回 None。 + assert _find_stream_session("run-2", "user-1") is None + + _unregister_stream("run-1") + assert _find_stream_session("run-1", "user-1") is None + # 重复 unregister 是安全 no-op。 + _unregister_stream("run-1") + + +def test_find_returns_session_but_cancel_is_caller_responsibility(): + _ACTIVE_STREAMS.clear() + session = FakeStreamSession() + _register_stream("run-1", "user-1", session) + + found = _find_stream_session("run-1", "user-1") + assert found is session + # helper 只负责定位与用户校验;取消由 cancel_workflow 在锁外执行。 + assert not session.cancelled + found.cancel() + assert session.cancelled + _unregister_stream("run-1") + + +def test_register_overwrites_previous_entry_for_same_run(): + _ACTIVE_STREAMS.clear() + first = FakeStreamSession() + second = FakeStreamSession() + _register_stream("run-1", "user-1", first) + _register_stream("run-1", "user-2", second) + + # 同一 run 最新注册生效,且按最新用户校验。 + assert _find_stream_session("run-1", "user-2") is second + assert _find_stream_session("run-1", "user-1") is None + _unregister_stream("run-1") diff --git a/apps/scut-senior/tests/python/test_iteration_7_materials_contributions.py b/apps/scut-senior/tests/python/test_iteration_7_materials_contributions.py index b75abd99..65655c14 100644 --- a/apps/scut-senior/tests/python/test_iteration_7_materials_contributions.py +++ b/apps/scut-senior/tests/python/test_iteration_7_materials_contributions.py @@ -64,6 +64,7 @@ def oauth_settings(database_path: Path) -> Settings: github_client_secret="test-client-secret", github_callback_url="https://testserver/api/v1/auth/github/callback", post_login_redirect_url="https://testserver/", + maintainer_github_logins=("maintainer",), ) @@ -483,6 +484,13 @@ def test_private_materials_and_contributions_are_user_scoped(tmp_path: Path) -> # --------------------------------------------------------------------------- +def test_non_allowlisted_github_user_cannot_access_maintainer_queue(tmp_path: Path) -> None: + app = create_app(oauth_settings(tmp_path / "allowlist.db")) + ordinary_user = authenticated_client(app, 2003, "ordinary-user") + response = ordinary_user.get("/api/v1/maintainer/contributions") + assert response.status_code == 403 + + def test_maintainer_queue_manual_progression_without_auto_merge( tmp_path: Path, ) -> None: @@ -747,3 +755,176 @@ def test_maintainer_export_package_returns_path_content_and_commands( ).status_code == 401 ) + + +# --------------------------------------------------------------------------- +# PLAN-3 C-1:贡献元数据、维护者详情与附件受控下载。 +# --------------------------------------------------------------------------- + + +def test_contribution_metadata_is_persisted_and_surface_on_detail( + tmp_path: Path, +) -> None: + app = create_app(oauth_settings(tmp_path / "metadata.db")) + maintainer = authenticated_client(app, 5001, "maintainer") + author = authenticated_client(app, 5002, "author") + + conversation = create_conversation(author) + material = save_material(author, conversation["conversation_id"]) + contribution = author.post( + "/api/v1/contributions", + json={ + "material_id": material["material_id"], + "course_id": "linear_algebra", + "confirmations": FULL_CONFIRMATIONS, + "github_email": "author@example.com", + "workflow_type": "knowledge_qa", + "supplementary_text": "补充说明文字。", + "citation_metadata": [{"course_id": "linear_algebra", "chunk_id": "c1"}], + "corpus_metadata": {"corpus_version": "corpus-test"}, + }, + ) + assert contribution.status_code == 201, contribution.text + record = contribution.json() + assert record["github_email"] == "author@example.com" + assert record["workflow_type"] == "knowledge_qa" + assert record["supplementary_text"] == "补充说明文字。" + + detail = maintainer.get( + f"/api/v1/maintainer/contributions/{record['contribution_id']}" + ) + assert detail.status_code == 200 + body = detail.json() + assert body["github_email"] == "author@example.com" + assert body["workflow_type"] == "knowledge_qa" + assert body["citation_metadata"] == [{"course_id": "linear_algebra", "chunk_id": "c1"}] + assert body["corpus_metadata"] == {"corpus_version": "corpus-test"} + assert "矩阵对角化要点" in body["content_snapshot"] + assert body["attachments"] == [] + + # 队列视图不回传正文,但可暴露元数据与附件标记。 + queue = maintainer.get("/api/v1/maintainer/contributions").json() + assert "content_snapshot" not in queue[0] + + +def test_contribution_detail_is_maintainer_only(tmp_path: Path) -> None: + app = create_app(oauth_settings(tmp_path / "detail-authz.db")) + maintainer = authenticated_client(app, 6001, "maintainer") + author = authenticated_client(app, 6002, "author") + outsider = authenticated_client(app, 6003, "outsider") + + conversation = create_conversation(author) + material = save_material(author, conversation["conversation_id"]) + contribution_id = author.post( + "/api/v1/contributions", + json={ + "material_id": material["material_id"], + "course_id": "linear_algebra", + "confirmations": FULL_CONFIRMATIONS, + }, + ).json()["contribution_id"] + + assert ( + maintainer.get(f"/api/v1/maintainer/contributions/{contribution_id}").status_code + == 200 + ) + # 普通用户通过维护者详情端点无权查看他人贡献全文。 + assert ( + outsider.get(f"/api/v1/maintainer/contributions/{contribution_id}").status_code + == 403 + ) + + +def test_attachment_upload_download_is_controlled(tmp_path: Path) -> None: + app = create_app(oauth_settings(tmp_path / "attachments.db")) + maintainer = authenticated_client(app, 7001, "maintainer") + author = authenticated_client(app, 7002, "author") + outsider = authenticated_client(app, 7003, "outsider") + + conversation = create_conversation(author) + material = save_material(author, conversation["conversation_id"]) + contribution_id = author.post( + "/api/v1/contributions", + json={ + "material_id": material["material_id"], + "course_id": "linear_algebra", + "confirmations": FULL_CONFIRMATIONS, + }, + ).json()["contribution_id"] + + # 允许的扩展名 + multipart 上传。 + multipart = ( + b'--BOUNDARY\r\n' + b'Content-Disposition: form-data; name="file"; filename="notes.md"\r\n' + b'Content-Type: text/markdown\r\n\r\n' + b'# attachment body\n' + b'\r\n--BOUNDARY--\r\n' + ) + uploaded = maintainer.post( + f"/api/v1/maintainer/contributions/{contribution_id}/attachments", + content=multipart, + headers={"Content-Type": "multipart/form-data; boundary=BOUNDARY"}, + ) + assert uploaded.status_code == 200, uploaded.text + attachment = uploaded.json() + assert attachment["original_filename"] == "notes.md" + assert attachment["byte_size"] == len(b"# attachment body\n") + assert attachment["sha256"] + + # 受控下载:固定维护者身份 + Content-Disposition: attachment。 + downloaded = maintainer.get( + f"/api/v1/maintainer/contributions/{contribution_id}/attachments/{attachment['attachment_id']}" + ) + assert downloaded.status_code == 200 + assert downloaded.headers["content-disposition"].startswith("attachment") + assert downloaded.content == b"# attachment body\n" + + # 详情端点展示附件元数据,不直接回传 BLOB。 + detail = maintainer.get(f"/api/v1/maintainer/contributions/{contribution_id}").json() + assert [a["attachment_id"] for a in detail["attachments"]] == [attachment["attachment_id"]] + assert "payload" not in detail["attachments"][0] + + # 普通用户无权上传或下载。 + assert ( + outsider.post( + f"/api/v1/maintainer/contributions/{contribution_id}/attachments", + content=multipart, + headers={"Content-Type": "multipart/form-data; boundary=BOUNDARY"}, + ).status_code + == 403 + ) + assert ( + outsider.get( + f"/api/v1/maintainer/contributions/{contribution_id}/attachments/{attachment['attachment_id']}" + ).status_code + == 403 + ) + + +def test_attachment_rejects_disallowed_extension(tmp_path: Path) -> None: + app = create_app(oauth_settings(tmp_path / "attachment-ext.db")) + maintainer = authenticated_client(app, 8001, "maintainer") + author = authenticated_client(app, 8002, "author") + + conversation = create_conversation(author) + material = save_material(author, conversation["conversation_id"]) + contribution_id = author.post( + "/api/v1/contributions", + json={ + "material_id": material["material_id"], + "course_id": "linear_algebra", + "confirmations": FULL_CONFIRMATIONS, + }, + ).json()["contribution_id"] + + # 压缩包不在第一版 allowlist。 + zip_part = ( + b'--B\r\nContent-Disposition: form-data; name="file"; filename="archive.zip"\r\n' + b'Content-Type: application/zip\r\n\r\nPK\x03\x04\r\n--B--\r\n' + ) + response = maintainer.post( + f"/api/v1/maintainer/contributions/{contribution_id}/attachments", + content=zip_part, + headers={"Content-Type": "multipart/form-data; boundary=B"}, + ) + assert response.status_code == 422 diff --git a/apps/scut-senior/tests/python/test_local_corpus_retrieval.py b/apps/scut-senior/tests/python/test_local_corpus_retrieval.py index 6acbb525..8b282fb7 100644 --- a/apps/scut-senior/tests/python/test_local_corpus_retrieval.py +++ b/apps/scut-senior/tests/python/test_local_corpus_retrieval.py @@ -186,7 +186,7 @@ def test_local_gateway_ranks_chinese_and_english_deterministically( ) -def test_local_gateway_hard_filters_one_course_and_fails_closed( +def test_local_gateway_fails_closed_for_unavailable_selected_courses( tmp_path: Path, ) -> None: store, _, _ = _build_store(tmp_path, enabled=False) @@ -195,7 +195,7 @@ def test_local_gateway_hard_filters_one_course_and_fails_closed( assert gateway.is_course_available(COURSE_ID) is False with pytest.raises(CapabilityUnavailable): gateway.search([COURSE_ID], "密码学") - with pytest.raises(CapabilityUnavailable, match="exactly one"): + with pytest.raises(CapabilityUnavailable): gateway.search([COURSE_ID, "cpp"], "密码学") (store / "active.json").write_text("{}\n", encoding="utf-8") diff --git a/apps/scut-senior/tests/python/test_maintenance_scheduler.py b/apps/scut-senior/tests/python/test_maintenance_scheduler.py index 00d4161f..6c7c20d9 100644 --- a/apps/scut-senior/tests/python/test_maintenance_scheduler.py +++ b/apps/scut-senior/tests/python/test_maintenance_scheduler.py @@ -31,7 +31,12 @@ def advance(self, delta: timedelta) -> None: class CountingRepository: - """只统计 sweep 调用次数的假仓储;用于启停时序验证。""" + """只统计 sweep 调用次数的假仓储;用于启停时序验证。 + + 计数字段与真实仓储契约一致(oauth_states / auth_sessions / + workflow_runs / conversations / feedback / materials / + contributions_cleared),便于直接检查 sweep 结果。 + """ def __init__(self): self.sweeps = 0 @@ -40,8 +45,8 @@ def cleanup_auth_records(self): self.sweeps += 1 class _Counts: - states = 0 - sessions = 0 + oauth_states = 0 + auth_sessions = 0 return _Counts() @@ -61,6 +66,45 @@ class _Counts: return _Counts() +class StepFailingRepository: + """按步骤注入失败的假仓储;用于验证清理步骤异常隔离(PLAN-3 §8.1)。""" + + def __init__(self, fail_step: str | None = None): + self.fail_step = fail_step + self.calls: list[str] = [] + + def _run(self, step_name: str, value): + self.calls.append(step_name) + if step_name == self.fail_step: + raise RuntimeError(f"simulated failure in {step_name}") + return value + + def cleanup_auth_records(self): + class _Counts: + oauth_states = 3 + auth_sessions = 2 + + return self._run("cleanup_auth_records", _Counts()) + + def cleanup_history_records(self): + class _Counts: + workflow_runs = 5 + conversations = 4 + feedback = 1 + + return self._run("cleanup_history_records", _Counts()) + + def cleanup_material_records(self): + class _Counts: + materials = 2 + contributions_cleared = 0 + + return self._run("cleanup_material_records", _Counts()) + + def cleanup_platform_quota_records(self): + return self._run("cleanup_platform_quota_records", 9) + + def make_repository(tmp_path: Path, clock: MutableClock) -> SQLiteWorkflowRepository: return SQLiteWorkflowRepository( tmp_path / "maintenance.db", @@ -179,6 +223,60 @@ def test_invalid_interval_rejected(bad_interval): MaintenanceScheduler(CountingRepository(), interval_seconds=bad_interval) +def test_sweep_isolates_failed_step_and_continues(caplog): + """PLAN-3 §8.1:单个清理步骤失败只归零该步骤,后续步骤继续执行。""" + + repository = StepFailingRepository(fail_step="cleanup_history_records") + scheduler = MaintenanceScheduler(repository, interval_seconds=3600) + result = scheduler.sweep() + + # 失败步骤计数按 0 处理,其余步骤正常计入。 + assert result.auth_states == 3 + assert result.auth_sessions == 2 + assert result.history_runs == 0 + assert result.history_conversations == 0 + assert result.history_feedback == 0 + assert result.materials == 2 + assert result.contributions_cleared == 0 + assert result.platform_rate_events == 9 + # 所有步骤仍按顺序执行,失败步骤留下日志。 + assert repository.calls == [ + "cleanup_auth_records", + "cleanup_history_records", + "cleanup_material_records", + "cleanup_platform_quota_records", + ] + assert "cleanup_history_records" in caplog.text + + +def test_sweep_failure_of_first_step_zeros_only_that_step(): + """PLAN-3 §8.1:首个步骤失败也不阻断后续步骤。""" + + repository = StepFailingRepository(fail_step="cleanup_auth_records") + scheduler = MaintenanceScheduler(repository, interval_seconds=3600) + result = scheduler.sweep() + + assert result.auth_states == 0 + assert result.auth_sessions == 0 + assert result.history_runs == 5 + assert result.history_conversations == 4 + assert result.history_feedback == 1 + assert result.materials == 2 + assert result.platform_rate_events == 9 + + +def test_sweep_without_quota_step_keeps_zero_count(): + """仓储未提供额度清理时,quota 计数保持 0 且不报错。""" + + repository = CountingRepository() + scheduler = MaintenanceScheduler(repository, interval_seconds=3600) + result = scheduler.sweep() + + assert result.platform_rate_events == 0 + assert result.auth_states == 0 + assert result.history_runs == 0 + + def test_settings_reject_non_positive_interval(): with pytest.raises(UnsafeRuntimeConfiguration): Settings( diff --git a/apps/scut-senior/tests/python/test_private_knowledge.py b/apps/scut-senior/tests/python/test_private_knowledge.py new file mode 100644 index 00000000..71dcdce0 --- /dev/null +++ b/apps/scut-senior/tests/python/test_private_knowledge.py @@ -0,0 +1,46 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from scut_senior_api.adapters.sqlite import SQLiteWorkflowRepository + + +def test_private_knowledge_is_filtered_by_user_and_selected_courses(tmp_path: Path) -> None: + now = datetime(2026, 1, 1, tzinfo=UTC) + repository = SQLiteWorkflowRepository(tmp_path / "private.db", clock=lambda: now) + alice = "alice" + bob = "bob" + repository.save_private_knowledge( + user_id=alice, course_id="linear_algebra", title="Alice 矩阵笔记", content="矩阵秩" + ) + repository.save_private_knowledge( + user_id=alice, course_id="probability_theory", title="Alice 概率笔记", content="随机变量" + ) + repository.save_private_knowledge( + user_id=bob, course_id="linear_algebra", title="Bob 矩阵笔记", content="另一份内容" + ) + + selected = repository.list_private_knowledge_sources( + user_id=alice, course_ids=["probability_theory"] + ) + assert [source.course_id for source in selected] == ["probability_theory"] + assert selected[0].text == "随机变量" + assert repository.list_private_knowledge_sources( + user_id=bob, course_ids=["probability_theory"] + ) == [] + + +def test_expired_private_knowledge_is_not_retrieved_and_is_physically_deleted( + tmp_path: Path, +) -> None: + current = [datetime(2026, 1, 1, tzinfo=UTC)] + repository = SQLiteWorkflowRepository(tmp_path / "private-expiry.db", clock=lambda: current[0]) + repository.save_private_knowledge( + user_id="alice", course_id="linear_algebra", title="过期笔记", content="旧内容" + ) + current[0] += timedelta(days=8) + assert repository.list_private_knowledge_sources( + user_id="alice", course_ids=["linear_algebra"] + ) == [] + repository.cleanup_material_records() + with repository.connect() as connection: + assert connection.execute("SELECT COUNT(*) FROM private_knowledge_items").fetchone()[0] == 0 diff --git a/apps/scut-senior/tests/python/test_registry.py b/apps/scut-senior/tests/python/test_registry.py index 52b30f0c..3e3c7c77 100644 --- a/apps/scut-senior/tests/python/test_registry.py +++ b/apps/scut-senior/tests/python/test_registry.py @@ -9,7 +9,7 @@ def test_registry_freezes_fifty_five_course_units() -> None: assert len(registry.records) == 55 assert all(course.is_open is False for course in registry.records) assert [course.course_id for course in registry.records if course.fixture_available] == [ - "linear_algebra" + "linear_algebra", ] diff --git a/apps/scut-senior/tests/python/test_sqlite_auth.py b/apps/scut-senior/tests/python/test_sqlite_auth.py index 611a8755..21d2abac 100644 --- a/apps/scut-senior/tests/python/test_sqlite_auth.py +++ b/apps/scut-senior/tests/python/test_sqlite_auth.py @@ -73,6 +73,8 @@ def test_auth_migrations_are_ledgered_and_sqlite_runtime_pragmas_are_enabled( "0013_exam_plan_decisions.sql", "0014_byok_cross_device.sql", "0015_user_preferences.sql", + "0016_private_knowledge.sql", + "0017_contribution_metadata_attachments.sql", ] assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1 assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal" diff --git a/apps/scut-senior/web/src/App.vue b/apps/scut-senior/web/src/App.vue index 2a7d12d2..47d5db07 100644 --- a/apps/scut-senior/web/src/App.vue +++ b/apps/scut-senior/web/src/App.vue @@ -5,8 +5,10 @@ import ConversationRail from "./components/ConversationRail.vue"; import TranscriptPanel from "./components/TranscriptPanel.vue"; import Composer from "./components/Composer.vue"; import { useAppStore } from "./composables/useAppStore"; +import MaintainerPanel from "./components/MaintainerPanel.vue"; const store = useAppStore(); +const isMaintainerRoute = window.location.pathname === "/maintainer"; // 浮层态的左轨与检查器需要 Escape 退出,否则窄屏下只能靠再次点按钮。 function onGlobalKeydown(event: KeyboardEvent): void { @@ -39,7 +41,8 @@ onBeforeUnmount(() => {