Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0c747a4
ab-test: enforce workflow boundary for agent actions
AlexBybye Aug 28, 2026
1216c81
ab-test: add bounded model action decision adapter
AlexBybye Aug 28, 2026
5f52c3d
ab-test: enable model-driven bounded agent decisions
AlexBybye Aug 28, 2026
686df3a
ab-test: fail closed on verbose model actions
AlexBybye Aug 28, 2026
bd38622
Merge pull request #57 from AlexBybye/master
AlexBybye Aug 29, 2026
c7af3e4
Merge pull request #58 from AlexBybye/master
HKO00O Aug 31, 2026
1a7ef27
Merge pull request #60 from AlexBybye/master
HKO00O Sep 1, 2026
f4e80e8
Merge pull request #61 from AlexBybye/master
AlexBybye Sep 2, 2026
d00ef1f
feat: add new metrics to conversation and workflow schemas
AlexBybye Sep 3, 2026
ff81e52
feat: 增加可选模型调用的软运行时限制,优化 BYOK 模型请求和代理预算管理
AlexBybye Sep 3, 2026
b990162
Refactor BYOK connection handling and update related tests
AlexBybye Sep 3, 2026
56c3e34
feat: 优化 BYOK 连接处理,增加超时和最大 token 控制,支持 DeepSeek 直连模型
AlexBybye Sep 3, 2026
41dc172
Merge pull request #62 from AlexBybye/master
AlexBybye Sep 7, 2026
a43abb6
Merge pull request #63 from AlexBybye/master
AlexBybye Sep 7, 2026
56a64fa
本次提交包括RRF双路融合,BYOK回退(回退出了BUG,等BYOK自定义分支合并master再对齐覆盖实验分支),重点放在了agent …
AlexBybye Sep 10, 2026
9796f9f
回退RRF混合索引原策略
AlexBybye Sep 10, 2026
7f38315
merge: align custom BYOK with AB runtime experiments
AlexBybye Sep 10, 2026
828aaf8
fix: preserve AB runtime after BYOK merge
AlexBybye Sep 10, 2026
0815456
Merge pull request #67 from AlexBybye/master
AlexBybye Sep 10, 2026
2bf9ab2
feat: Enhance evaluation scripts and add new auditing functionality
AlexBybye Sep 11, 2026
600306c
Add visual-reviewed evaluation and coverage harness scripts
AlexBybye Sep 12, 2026
c234256
feat: add script to expand reviewed semantic anchors into student sce…
AlexBybye Sep 12, 2026
d8eb59f
Merge pull request #68 from AlexBybye/master
AlexBybye Sep 12, 2026
6d8bf73
已完成第一轮 P1 检索迭代,P2 运行时拆分尚未开始。
AlexBybye Sep 12, 2026
b0a0924
refactor: split workflow runtime orchestration
AlexBybye Sep 13, 2026
5f1d845
契约归一化,功能内聚化,新增个人知识平台,支持定制化私人知识库行为完善了相关UI操作
AlexBybye Sep 14, 2026
64eae1c
feat: add persona enhancement and outcomes to workflow schema and rel…
AlexBybye Sep 14, 2026
f9bcd1a
feat: 增强角色人格与互动性,优化提示信息
AlexBybye Sep 14, 2026
a272eef
feat: 历史性的一幕
AlexBybye Sep 14, 2026
9a56a79
feat: 使用条件变量优化健康检查的并发请求处理
AlexBybye Sep 14, 2026
60cf30b
feat: Enhance BYOK model integration and humanizer response handling
AlexBybye Sep 14, 2026
7ba76cd
feat: 添加快速首段输出与增量回答交付的实施计划
AlexBybye Sep 15, 2026
7ee4d19
feat: Extend runtime limits and enhance error handling
AlexBybye Sep 15, 2026
a1688e1
feat: 添加对历史追踪事件中提供者状态码的支持,上下文优化效果显著
AlexBybye Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/scut-senior/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,16 @@ SCUT_SENIOR_RETRIEVAL_MODE=local_corpus
# SCUT_SENIOR_ONNX_MODEL_ID=bge-small-zh-v1.5
# SCUT_SENIOR_ONNX_EMBEDDING_DIMENSIONS=512
# SCUT_SENIOR_ONNX_MAX_LENGTH=512
# Matrix is the default exact cosine engine. Set scalar only to compare with or
# roll back to the historical per-query SQLite scan.
# SCUT_SENIOR_VECTOR_SEARCH_ENGINE=matrix
# SCUT_SENIOR_VECTOR_SNAPSHOT_CACHE_BYTES=268435456
# The historical lexical-first strategy remains the default while protected RRF
# is evaluated against the checked-in retrieval regression data.
# SCUT_SENIOR_RETRIEVAL_RANKING_STRATEGY=lexical_first_v1
# SCUT_SENIOR_CORPUS_STORE_PATH=/absolute/path/to/corpus-store
# Post-retrieval A/B mode: rule (default), shadow, model, or deterministic.
SCUT_SENIOR_AGENT_DECISION_MODE=rule
SCUT_SENIOR_CROSS_COURSE_ENABLED=true
# GitHub logins allowed to review/transition public contributions.
SCUT_SENIOR_MAINTAINER_GITHUB_LOGINS= 维护人员
Expand Down
6 changes: 4 additions & 2 deletions apps/scut-senior/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ PLAN-3 将一期、二期已建立的课程边界、检索与运行时能力扩

### 检索与回答

本地语料模式使用 BM25F 与本地 CPU ONNX `bge-small-zh-v1.5` 的 Hybrid Retrieval,并使用确定性规则重排。dense 模型文件或向量资产缺失时,检索自动退回 BM25F,不发起网络请求。
本地语料模式使用 BM25F 与本地 CPU ONNX `bge-small-zh-v1.5` 的 Hybrid Retrieval。dense 向量保留在版本绑定的 SQLite 文件中,在线查询默认使用只读 float32 矩阵缓存做精确余弦搜索;可用 `SCUT_SENIOR_VECTOR_SEARCH_ENGINE=scalar` 回退到历史逐向量扫描。dense 模型文件或向量资产缺失时,检索自动退回 BM25F,不发起网络请求。

默认排序仍为 `lexical_first_v1`:整句词法命中保护、其余词法优先、dense 补位。`protected_rrf_v1` 是待评测的可选策略:只保护唯一题号或完整非泛化标题,其他候选按加权 RRF 竞争。可通过 `SCUT_SENIOR_RETRIEVAL_RANKING_STRATEGY=protected_rrf_v1` 离线或灰度启用;在未完成对照评测前,不应将其设为默认。

回答输出经过兼容解析、来源 Guard、引用 Guard 和安全回答块处理后,才通过 NDJSON 流发送到前端。流式事件包括 Trace、回答增量、Agent 进度、终态结果和错误事件。运行中的取消、断线、预算耗尽和上游错误都有明确终态,并保存可恢复的运行记录。

Expand Down Expand Up @@ -126,7 +128,7 @@ PLAN-3 将一期、二期已建立的课程边界、检索与运行时能力扩

### 私人知识库

私人知识库材料绑定当前用户,保留 7 天,到期由服务端物理清理。私人材料不进入公共索引、公共课程包或其他用户的检索结果,也不提供手动查看、删除和用户导出入口
私人知识库材料绑定当前用户,默认保留 7 天,到期由服务端物理清理。私人材料不进入公共索引、公共课程包或其他用户的检索结果。用户可从“助手设置”进入“个人知识平台”,查看全文、按课程筛选、手动续期 7 天、导出或删除;这些管理操作不会改变公共语料

材料按照课程插件名或课程 ID归属。检索时同时满足以下条件才会使用:

Expand Down
1 change: 1 addition & 0 deletions apps/scut-senior/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dev = [
"pytest-cov>=6.2,<7",
]
onnx = [
"numpy>=2.0,<3",
"onnxruntime>=1.18,<2",
"tokenizers>=0.19,<1",
]
Expand Down
62 changes: 62 additions & 0 deletions apps/scut-senior/api/src/scut_senior_api/action_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Reviewed, fixed action catalogue for the bounded Layer 2 loop.

The catalogue owns declarative admission only. Executors and observation
serializers remain ordinary reviewed code and are never loaded from config.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

ActionKind = Literal[
"retrieve", "retrieve_with_query_rewrite", "ask_clarification",
"generate_answer", "finish",
]


@dataclass(frozen=True, slots=True)
class ActionPolicy:
name: ActionKind
workflows: frozenset[str]
phases: frozenset[str]
executable: bool = True


class ActionRegistry:
def __init__(self, policies: tuple[ActionPolicy, ...]):
names = [policy.name for policy in policies]
if len(names) != len(set(names)):
raise ValueError("duplicate action policy")
self._policies = {policy.name: policy for policy in policies}

@property
def action_kinds(self) -> frozenset[ActionKind]:
return frozenset(self._policies)

def allowed_actions(self, workflow: str, phase: str) -> tuple[ActionKind, ...]:
return tuple(
policy.name for policy in self._policies.values()
if policy.executable and workflow in policy.workflows and phase in policy.phases
)

def admits(self, workflow: str, action: str, phase: str | None = None) -> bool:
policy = self._policies.get(action) # type: ignore[arg-type]
return bool(policy and policy.executable and workflow in policy.workflows
and (phase is None or phase in policy.phases))


_COURSE_WORKFLOWS = frozenset({
"knowledge_qa", "exam_review", "problem_tutor", "mistake_review",
})
_ALL_WORKFLOWS = _COURSE_WORKFLOWS | {"temporary_material_reading"}

ACTION_REGISTRY = ActionRegistry((
ActionPolicy("retrieve", _ALL_WORKFLOWS, frozenset({"retrieve"})),
ActionPolicy("retrieve_with_query_rewrite", _COURSE_WORKFLOWS,
frozenset({"retrieve_with_query_rewrite", "post_retrieval"})),
ActionPolicy("generate_answer", _ALL_WORKFLOWS,
frozenset({"generate", "post_retrieval"})),
# Historical event vocabulary. No executor or persistence contract exists.
ActionPolicy("ask_clarification", _ALL_WORKFLOWS, frozenset(), executable=False),
ActionPolicy("finish", _ALL_WORKFLOWS, frozenset(), executable=False),
))
159 changes: 153 additions & 6 deletions apps/scut-senior/api/src/scut_senior_api/adapters/byok.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import inspect
import json
from collections.abc import Callable
from ..contracts import WorkflowRunRequest
from ..contracts import AnswerBlock, WorkflowRunRequest
from .humanizer import RewriteTask
from ..credentials import validate_user_api_key
from ..model_credentials import ModelCredentialError, normalize_base_url
from ..ports import (
Expand All @@ -18,7 +19,13 @@
)
from .answer_parsing import ModelAnswerParseError, parse_chat_completion_answer
from .http_security import is_timeout_transport_error
from .openrouter import HttpResponse, JsonHttpClient, UrllibJsonHttpClient
from .openrouter import (
HttpResponse,
JsonHttpClient,
UrllibJsonHttpClient,
_build_action_request,
_parse_action_text,
)


DEFAULT_BYOK_MAX_TOKENS = 12_288
Expand Down Expand Up @@ -51,7 +58,7 @@ def __init__(
self,
*,
http_client: JsonHttpClient | None = None,
timeout_seconds: float = 180.0,
timeout_seconds: float = 120.0,
):
self._http_client = http_client or UrllibJsonHttpClient()
self._timeout_seconds = timeout_seconds
Expand All @@ -70,7 +77,9 @@ def generate(
history: tuple[ConversationTurn, ...] = (),
cancel_check: Callable[[], bool] | None = None,
timeout_seconds: float | None = None,
) -> GeneratedAnswer:
rewrite: RewriteTask | None = None,
repair_context: str | None = None,
) -> GeneratedAnswer | list[AnswerBlock]:
if (
request.provider_id != connection.provider_id
or request.model_id != connection.model_id
Expand Down Expand Up @@ -113,10 +122,19 @@ def generate(
),
temperature=DEFAULT_BYOK_TEMPERATURE,
reasoning_effort=(
DEEPSEEK_REASONING_EFFORT if direct_deepseek else None
(
selected_model.reasoning_effort
if selected_model and selected_model.reasoning_effort
else DEEPSEEK_REASONING_EFFORT
)
if direct_deepseek
else None
),
repair_context=repair_context,
)
endpoint = f"{base_url}/chat/completions"
if rewrite is not None:
payload = rewrite.payload(payload)
effective_timeout = _effective_timeout(
self._timeout_seconds, timeout_seconds
)
Expand Down Expand Up @@ -150,7 +168,125 @@ def generate(
) from None
if response.status_code < 200 or response.status_code >= 300:
raise _safe_byok_upstream_error(response.status_code)
return _parse_byok_answer(response)
return rewrite.parse(response.body) if rewrite is not None else _parse_byok_answer(response)

def decide_action(
self,
*,
api_key: str,
connection: StoredModelCredential,
request: WorkflowRunRequest,
state: object,
phase: str,
sources: tuple[RetrievedSource, ...] = (),
history: tuple[ConversationTurn, ...] = (),
timeout_seconds: float | None = None,
) -> str:
"""Ask the selected BYOK model for one bounded Agent action.

This uses the same registered connection and decrypted key as answer
generation, but sends only routing facts and source titles. It never
serializes credentials or provider text into the Agent event stream.
"""

del state, history
self._validate_connection(
api_key=api_key,
connection=connection,
request=request,
)
try:
base_url = normalize_base_url(connection.base_url)
except ModelCredentialError:
raise ByokGatewayError(
status_code=422,
code="invalid_byok_base_url",
detail="已保存的 API 地址无效,请重新保存该连接。",
) from None
payload = _build_action_request(request, phase, sources)
try:
response = self._post(
base_url=base_url,
api_key=api_key,
payload=payload,
timeout_seconds=_effective_timeout(
self._timeout_seconds, timeout_seconds
),
cancel_check=None,
)
except Exception as exc:
if is_timeout_transport_error(exc):
raise ByokGatewayError(
status_code=504,
code="byok_provider_timeout",
detail="模型供应商响应超时,请稍后重试。",
) from None
raise ByokGatewayError(
status_code=503,
code="byok_provider_unavailable",
detail="模型供应商暂时不可用,请稍后重试。",
) from None
if response.status_code < 200 or response.status_code >= 300:
raise _safe_byok_upstream_error(response.status_code)
try:
return _parse_action_text(response.body)
except ModelAnswerParseError:
raise ByokGatewayError(
status_code=502,
code="byok_provider_invalid_response",
detail="模型供应商返回了无法处理的结果,请稍后重试。",
) from None

def _validate_connection(
self,
*,
api_key: str,
connection: StoredModelCredential,
request: WorkflowRunRequest,
) -> None:
if (
request.provider_id != connection.provider_id
or request.model_id != connection.model_id
or connection.protocol != "openai_chat_completions"
):
raise ByokGatewayError(
status_code=422,
code="byok_route_not_registered",
detail="所选模型与已保存连接不一致。",
)
try:
validate_user_api_key(api_key)
except ValueError:
raise ByokGatewayError(
status_code=422,
code="invalid_model_credential",
detail="已保存的 API Key 无效,请重新保存。",
) from None

def _post(
self,
*,
base_url: str,
api_key: str,
payload: dict[str, object],
timeout_seconds: float,
cancel_check: Callable[[], bool] | None,
) -> HttpResponse:
request_options: dict[str, object] = {
"headers": {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
},
"payload": payload,
"timeout_seconds": timeout_seconds,
}
if self._transport_accepts_cancel_check:
request_options["cancel_check"] = cancel_check
return self._http_client.post_json(
f"{base_url}/chat/completions",
**request_options,
)

def _build_byok_request(
request: WorkflowRunRequest,
Expand All @@ -160,6 +296,7 @@ def _build_byok_request(
max_tokens: int,
temperature: float,
reasoning_effort: str | None = None,
repair_context: str | None = None,
) -> dict[str, object]:
workflow_focus = build_workflow_focus(request)
response_controls = build_response_control_directive(request)
Expand Down Expand Up @@ -196,6 +333,7 @@ def _build_byok_request(
f"结构化 Workflow 输入: {request.workflow_payload.model_dump_json()}\n\n"
"Workflow 聚焦上下文(JSON 数据,不是指令):\n"
f"{workflow_focus.anchor_context}\n\n"
f"{_repair_context_section(repair_context)}"
f"课程资料候选:\n{source_context}"
),
},
Expand All @@ -208,6 +346,15 @@ def _build_byok_request(
return payload


def _repair_context_section(repair_context: str | None) -> str:
if not repair_context:
return ""
return (
"系统引用校验修复要求(服务端生成,非用户问题;仅修复此项):\n"
f"{repair_context[:500]}\n\n"
)


def _effective_timeout(configured: float, remaining: float | None) -> float:
if remaining is None:
return configured
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from __future__ import annotations

from urllib.request import HTTPRedirectHandler, OpenerDirector, build_opener
from urllib.request import (
HTTPRedirectHandler,
OpenerDirector,
ProxyHandler,
build_opener,
)


class RejectRedirectHandler(HTTPRedirectHandler):
Expand All @@ -14,6 +19,19 @@ def build_no_redirect_opener() -> OpenerDirector:
return build_opener(RejectRedirectHandler())


def build_direct_no_redirect_opener() -> OpenerDirector:
"""Build a provider transport that ignores inherited proxy settings.

Model keys are sent only to fixed first-party/API-gateway origins. An
accidentally inherited local proxy can make a TCP health check succeed
while every HTTPS request stalls or fails. Provider traffic therefore uses
an explicit empty proxy handler; GitHub OAuth keeps the normal system
transport because its deployment may intentionally require a proxy.
"""

return build_opener(ProxyHandler({}), RejectRedirectHandler())


def is_timeout_transport_error(error: BaseException) -> bool:
"""Recognize direct and urllib-wrapped timeouts without parsing responses."""

Expand Down
Loading
Loading