Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
113 changes: 113 additions & 0 deletions KNOWLEDGE_RAG_STAGE14.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# 阶段十四:知识检索与 RAG|嵌入替换、成本上限与可观测优化

能力主题键:`CodeSense:knowledge-rag:stage14`

## 系统地图

```text
AssignmentKnowledgePoint rows
|
v
privacy filter -> chunker -> request-scoped VersionedKnowledgeIndex
|
v
embedding provider boundary
(cjk_ngram default / token candidate)
|
+--> call and cost budget
+--> bounded latency counters
v
vector -> keyword -> priority fallback
|
v
safe citations -> answer-only fallback
|
+--> public bounded metrics
+--> offline quality comparison
```

## 先验观察、假设与成功指标

### 已确认观察

1. `services/knowledge_rag.py::retrieve_assignment_knowledge()` 在当前作业知识点范围内按请求构建索引,阶段十三已提供隐私过滤、版本控制、限流、deadline 和质量监控。
2. 学生端默认固定使用 `NgramCountEmbedder`;`services/knowledge_pipeline.py` 已有可独立运行的 `TokenCountEmbedder`,但此前没有统一 provider 选择、成本边界或对比报告。
3. `services/knowledge_eval.py` 已有固定离线集和 recall/latency 统计,可以作为 provider 替换前后的可复现基线。
4. 对阶段十三主线合并结果做 API 回归时发现:内部 `timeout`、`rate_limited` 状态被 `services/knowledge_evidence.py` 的旧白名单投影成 `unknown`,导致 answer-only 回退的状态丢失。

### 可证伪假设

在不改数据库结构、权限、部署和核心 JSON/SSE 字段的前提下,把嵌入器放入显式 registry,并对每个请求限制调用次数和预估成本,可以支持离线 provider 替换,同时让 provider、调用数、成本和预算回退可观测;若候选 provider 在固定集上的 `recall_at_k` 不低于基线,则可以作为后续候选。修复公共投影白名单后,超时和限流状态应原样到达 API,而不是变成 `unknown`。

### 成功指标

- 固定集基线 `recall_at_k=0.875`;候选 provider 的 `recall_at_k` 不低于基线,且通过成本门槛。
- 当前本地 provider 的预估成本为 `0`;RAG 请求默认成本上限为 `0`,未来付费 provider 必须显式登记单次成本并在上限内运行。
- 单个请求最多为 64 个索引切片加 1 次查询调用,即 `65` 次 embedding;超限在调用前拒绝并回到 answer-only。
- provider、调用数、成本、预算拒绝和延迟只以有界计数返回,不保存问题文本、答案或学生隐私。
- `timeout`、`rate_limited` 继续返回原有 HTTP 200 answer-only 路径,并保留明确回退码。

## 变更范围

- `services/knowledge_optimization.py`:新增 provider spec/registry、请求级 `BudgetedEmbedder`、调用/成本/延迟快照和环境成本上限 `KNOWLEDGE_RAG_EMBEDDING_MAX_COST`。
- `services/knowledge_optimization_eval.py`:使用现有固定集比较 `cjk_ngram` 与 `token`,输出 recall、延迟、调用数、成本和质量/成本门禁结果。
- `services/knowledge_eval.py`:为固定评估增加可注入 embedder,不改变默认评估结果。
- `services/knowledge_rag.py`:默认仍使用 `cjk_ngram`;显式设置 `KNOWLEDGE_RAG_EMBEDDER=token` 才切换,返回新增的安全 embedding 指标,预算拒绝沿用 unavailable/answer-only 回退。
- `services/knowledge_evidence.py`:补齐 `timeout`、`rate_limited` 状态、回退码和公共安全指标白名单,避免可靠性状态被投影成 `unknown`。
- `routes/api.py`:在既有无查询内容日志中补充 provider、调用数、成本和预算状态。
- `tests/test_knowledge_optimization.py`、`tests/test_knowledge_rag.py`、`tests/test_knowledge_evidence.py`:覆盖预算拒绝、provider 切换、质量/成本对比和可靠性状态投影。

不做:数据库表/字段、生产权限、真实 Redis、外部 embedding API、持久化向量库、跨作业缓存、部署变更、学生端既有字段删除或改名。当前 registry 只登记两个标准库离线 provider;它是替换边界和评估工具,不代表已接入生产模型。

## 学习总结与 AI 辅助边界

本阶段的关键学习是把“模型替换”和“模型一定更好”分开:registry 只解决构造和边界,固定集才负责比较质量,预算封装负责成本和失败前置,回退路径负责兼容性。嵌入调用应按请求隔离,不能把预算计数放到跨请求全局对象,否则一次演练会耗尽后续请求额度。

采纳的 AI 辅助建议:保留旧 provider 为默认值;使用固定离线集和可注入时钟/故障对象;把质量门禁和成本门禁写成可测试数据;把超限作为安全回退而不是把异常传播给学生端。未采纳的扩大方案:直接接入外部模型、Redis 全局计费或持久化索引,因为这些会引入凭据、权限、部署、跨实例一致性和数据迁移决策。

## 验证命令与结果

环境:`pr-stage14-rag-optimization` 隔离 worktree,使用共享 `student-eval` Python;未使用生产数据库、生产 Redis、外部 AI 或生产凭据。

基线(改动前):

```text
python -m services.knowledge_eval
recall_at_1=0.875, recall_at_k=0.875
mode_counts=keyword_fallback:1, no_result:1, vector:3
```

阶段十四对比:

```text
python -m services.knowledge_optimization_eval
cjk_ngram: recall_at_k=0.875, calls=181, estimated_cost=0.0,
quality_gate=True, cost_gate=True, selected=True
token: recall_at_k=0.875, calls=181, estimated_cost=0.0,
quality_gate=True, cost_gate=True, selected=False
```

测试与静态检查:

```text
python -m pytest tests/test_knowledge_evidence.py tests/test_knowledge_reliability.py tests/test_knowledge_rag.py tests/test_knowledge_optimization.py tests/test_knowledge_eval.py -q --disable-warnings
46 passed, exit code 0

python -c "from pathlib import Path; [compile(p.read_text(encoding='utf-8'), str(p), 'exec') for p in [Path('services/knowledge_optimization.py'), Path('services/knowledge_optimization_eval.py'), Path('services/knowledge_eval.py'), Path('services/knowledge_rag.py'), Path('services/knowledge_evidence.py')]]"
git diff --check
通过
```

另外,阶段十三既有可靠性集合在本阶段回归中包含限流和超时 API 用例;修复前两例因公共投影返回 `unknown` 失败,修复后与阶段十四测试一起为 `46 passed`。本地日志中的 Redis 未安装/连接失败属于测试环境降级提示,不是本阶段新增失败。

## 事实、推断与未解决问题

- 已确认事实:默认 provider 未改变;`token` 通过环境变量显式切换;固定集两个 provider 均达到 `recall_at_k=0.875`;成本和调用预算由请求级封装执行;可靠性状态现在能通过公共投影;所有改动不触碰数据库 schema、权限或部署。
- 仅属推断:固定集只有 5 个查询,不能推出生产数据上的模型优劣;标准库 provider 的零成本不等于外部模型零成本;单次本地延迟不能代表生产 SLA。
- 未解决:没有接入真实 embedding 模型、生产账单或多实例全局配额;没有管理端质量趋势页面;当前 registry 仅包含离线候选;阶段十三的单进程限流、请求内索引和未验证的真实 Redis/生产数据库边界仍然成立。

## 回滚与后续建议

回滚只需撤回本阶段新增的两个 optimization 模块、相关测试/文档,并将 `knowledge_eval.py` 的可选 embedder、`knowledge_rag.py` 的请求级 wrapper 和 `knowledge_evidence.py` 的状态白名单恢复到本 PR 前版本;不需要数据库或部署回滚。阶段十三的 `timeout`/`rate_limited` 投影修复应保留,除非维护者明确恢复旧 API 行为。

后续建议:先由维护者提供脱敏、规模更大的评估集和真实成本口径,再决定是否登记外部 embedding provider;若需要跨实例配额,应单独评审 Redis/账单/权限方案,不把本阶段的进程内计数直接扩展成生产全局配额。
52 changes: 52 additions & 0 deletions KNOWLEDGE_RAG_STAGE15.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 阶段十五:真实学生入口集成与失败可恢复路径

能力主题键:`CodeSense:knowledge-rag:stage15`

## 真实入口

本次选择学生代码工作台的聊天入口:

```text
学生打开 /submit/<assignment_id>
-> submit_code.html 的 sendToAI()
-> POST /api/code_advice(SSE)
-> assignment-scoped knowledge retrieval
-> AI done/error 事件
-> 页面显示回答和知识证据回执
```

阶段十四已经让 `/api/code_advice` 使用可预算的 RAG provider,并在成功的 `done` 事件中返回安全的 `knowledge_evidence`。本阶段关注真实使用中的失败边界:流式 AI 在已经输出部分内容后中断时,旧前端会吞掉 `error` 事件,只留下空白状态,且用户看不到本次检索是否成功。

## 改动

- `routes/api.py`:代码建议 SSE 的空回答、LLM 流中断和未知异常都通过同一失败事件构造器返回;如果本次请求有作业知识证据,则以现有安全投影同时放在事件顶层和 `data` 中。没有作业上下文时不新增跨作业检索数据。
- `templates/submit_code.html`:不再吞掉 SSE 错误。前端停止继续消费失败流,将错误交给统一失败处理,向学生显示可理解的错误信息,并保留已经得到的知识证据回执。正常 `done` 和旧的非流式响应路径保持不变。
- `tests/test_code_advice_knowledge.py`:补充真实 `/api/code_advice` SSE 中断集成测试,验证回答失败时证据状态仍可见。
- `tests/test_knowledge_evidence_integration.py`:验证学生提交页包含证据回执挂载点、流中断提示和错误状态传递钩子。

## 事实与边界

已验证:真实 Flask 登录会话、作业权限、作业级知识检索、`/api/code_advice` SSE 成功路径和中断路径;中断时不会把私有评分或 prompt 放入公开事件。

未验证:真实外部 AI provider、浏览器端真实网络断线、生产 Redis、生产数据库和部署环境。浏览器仍可能在 TCP 断开时无法取得服务端错误事件,此时由页面已有的网络异常提示兜底。

## 回滚与复现

回滚本次提交即可恢复原前端和 SSE 事件行为;不涉及数据库结构、权限、部署和核心成功响应字段。

在隔离 worktree 中运行:

```powershell
python -m pytest tests/test_code_advice_knowledge.py tests/test_knowledge_evidence_integration.py -q --disable-warnings
python -m pytest -q --disable-warnings
```

实际结果:集成测试 `7 passed`;全量测试 `749 passed`,退出码 0,耗时约 17 分 17 秒。Python 静态编译、`git diff --check` 和 10 个不含 Jinja 占位符的页面脚本块语法校验均通过;含 Jinja 变量的 1 个脚本块需在 Flask 渲染后校验。

测试结果和 commit SHA 也会写入 PR 描述及任务台验证结果。

## 后续风险

1. 真实浏览器断网和代理中断仍需补端到端演练。
2. 生产 provider 的成本、超时和全局配额仍属于阶段十四未决边界。
3. 任务完成仍需 PR 评审和隔离验证通过。
47 changes: 31 additions & 16 deletions routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,8 @@ def _retrieve_knowledge_context(assignment_id, query="", *, limit=MAX_EVIDENCE):
"knowledge_rag status=%s candidates=%s hits=%s latency_ms=%.2f "
"citation_completeness=%.3f no_result_fallback=%s "
"retrieval_error_fallback=%s retrieval_mode=%s indexed_chunks=%s "
"fallback_code=%s",
"fallback_code=%s embedding_provider=%s embedding_calls=%s "
"embedding_cost=%s embedding_budget_exceeded=%s",
retrieval["status"],
metrics.get("candidate_count", 0),
metrics.get("hit_count", 0),
Expand All @@ -419,6 +420,10 @@ def _retrieve_knowledge_context(assignment_id, query="", *, limit=MAX_EVIDENCE):
metrics.get("retrieval_mode", "unknown"),
metrics.get("indexed_chunk_count", 0),
(retrieval.get("fallback") or {}).get("code"),
metrics.get("embedding_provider"),
metrics.get("embedding_calls", 0),
metrics.get("embedding_estimated_cost", 0.0),
metrics.get("embedding_budget_exceeded", False),
)
return retrieval

Expand Down Expand Up @@ -1253,6 +1258,19 @@ def generate():
'type': 'start',
'message': '正在根据你的问题分析代码...'
})

def stream_error_event(code, message):
"""Keep the evidence receipt available on a failed stream."""
payload = {
'type': 'error',
'error': code,
'message': message,
}
if knowledge_fields:
payload.update(knowledge_fields)
payload['data'] = dict(knowledge_fields)
return payload

try:
for content in shared_client.chat_stream(
messages,
Expand All @@ -1269,11 +1287,10 @@ def generate():

full_content = ''.join(chunks)
if not full_content:
yield sse_event({
'type': 'error',
'error': 'AI_EMPTY_RESPONSE',
'message': 'AI服务未返回有效内容,请稍后重试',
})
yield sse_event(stream_error_event(
'AI_EMPTY_RESPONSE',
'AI服务未返回有效内容,请稍后重试',
))
return
yield sse_event({
'type': 'done',
Expand All @@ -1287,18 +1304,16 @@ def generate():
**knowledge_fields,
})
except LLMServiceError as exc:
yield sse_event({
'type': 'error',
'error': exc.code,
'message': 'AI服务流式输出中断,请稍后重试',
})
yield sse_event(stream_error_event(
exc.code,
'AI服务流式输出中断,请稍后重试',
))
except Exception as exc:
current_app.logger.warning('代码建议流式输出失败: %s', type(exc).__name__)
yield sse_event({
'type': 'error',
'error': 'AI_STREAM_FAILED',
'message': 'AI服务流式输出失败,请稍后重试',
})
yield sse_event(stream_error_event(
'AI_STREAM_FAILED',
'AI服务流式输出失败,请稍后重试',
))

return sse_response(generate())

Expand Down
14 changes: 9 additions & 5 deletions services/knowledge_eval.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Fixed, offline evaluation for the stage 12 knowledge retrieval prototype."""
"""Fixed, offline evaluation for the knowledge retrieval prototype."""

from __future__ import annotations

Expand Down Expand Up @@ -80,8 +80,9 @@ def evaluate_fixture(
path: str | Path = DEFAULT_FIXTURE,
*,
clock=time.perf_counter,
embedder=None,
) -> dict[str, Any]:
"""Run the fixed query set and report recall, modes, and latency."""
"""Run the fixed query set with an optional replaceable embedder."""

documents, queries, performance_spec = load_fixture(path)
by_id = {document.document_id: document for document in documents}
Expand Down Expand Up @@ -111,7 +112,7 @@ def evaluate_fixture(
for document in scoped_documents
for chunk in chunker.split(document)
)
index = HybridKnowledgeIndex(chunks)
index = HybridKnowledgeIndex(chunks, embedder=embedder)
build_latencies.append((clock() - build_started_at) * 1000.0)
total_indexed_chunks += index.indexed_chunk_count
query_started_at = clock()
Expand Down Expand Up @@ -154,7 +155,7 @@ def evaluate_fixture(
for document in performance_documents
for chunk in chunker.split(document)
)
performance_index = HybridKnowledgeIndex(performance_chunks)
performance_index = HybridKnowledgeIndex(performance_chunks, embedder=embedder)
performance_build_ms = (clock() - performance_build_started_at) * 1000.0
performance_query_latencies = []
performance_runs = max(1, int(performance_spec.get("runs", 100)))
Expand All @@ -166,7 +167,7 @@ def evaluate_fixture(
performance_query_latencies.append((clock() - query_started_at) * 1000.0)
performance_total_ms = (clock() - performance_total_started_at) * 1000.0

return {
result = {
"query_count": len(queries),
"relevant_query_count": relevant_case_count,
"recall_at_1": round(sum(recall_at_1_values) / relevant_case_count, 3)
Expand Down Expand Up @@ -206,6 +207,9 @@ def evaluate_fixture(
),
},
}
if embedder is not None and hasattr(embedder, "snapshot"):
result["embedding_usage"] = embedder.snapshot()
return result


def main() -> None:
Expand Down
Loading