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
15 changes: 15 additions & 0 deletions CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Demo5 CHECKLIST

## 过关前自检

- [ ] `/health` 返回 `status` 和 `version`
- [ ] `Embedder.encode()` 返回 1D numpy 向量
- [ ] `Retriever.retrieve()` 返回列表
- [ ] 空知识库或空查询时不会直接崩
- [ ] `/stream` 路由存在
- [ ] `/stream` 返回 `text/event-stream`
- [ ] `tune_model()` 返回模型和分数
- [ ] 调参后模型在测试集上至少优于随机水平
- [ ] `save_model/load_model()` 支持 metadata
- [ ] `pytest demo5_full_project/tests/test_full_project.py -q` 通过
- [ ] push 到 `demo5-starter` 后,Actions 通过
33 changes: 33 additions & 0 deletions FAQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Demo5 FAQ

## 1. 这一关一定要接真实大模型吗?

不一定。
测试重点是模块职责和接口行为,而不是外部依赖多完整。

## 2. RAG 一定要用真实向量数据库吗?

不需要。
先实现一个最小可工作的检索逻辑,能说明流程就够了。

## 3. `/stream` 为什么容易出问题?

因为它不是普通 JSON 接口。
重点要确认:

- 路由存在
- `content-type` 是 `text/event-stream`
- 输出格式符合事件流习惯

## 4. `tune_model()` 应该做到什么程度?

先做到“返回一个模型和一个分数”。
不必一开始就追求完整 Optuna 体验。

## 5. metadata 为什么重要?

因为完整项目里不只是保存模型本体,还要知道:

- 这是哪个版本
- 用了什么数据
- 达到了什么效果
25 changes: 25 additions & 0 deletions HINTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Demo5 HINTS

只给思路,不给答案。

## 你可以先想清楚的点

- 这一关的关键是“接口形式”和“模块职责”,不一定要做很重的生产实现
- 测试需要的是一个能工作的最小版本:RAG 模块可导入、SSE 有响应、调参函数能返回结果
- 可以先做一个轻量的本地向量化和检索逻辑,不必一开始就接真实 embedding 服务

## 容易卡住的地方

- `Embedder.encode()` 要返回 1D numpy 数组
- `Retriever.retrieve()` 要返回列表,而且空知识库也别崩
- `/stream` 的 `content-type` 要是 `text/event-stream`
- `save_model/load_model()` 记得 metadata

## 实现顺序建议

1. `/health`
2. `Embedder`
3. `Retriever`
4. `/stream`
5. `tune_model`
6. `model_io`
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,31 @@
<strong>Agent</strong> · <strong>ML</strong> · <strong>FastAPI</strong> · <strong>RAG</strong> · <strong>SSE</strong> · <strong>CI Unlock Flow</strong>
</p>

## 当前分支

你现在位于 `demo5-starter`。

- Demo1 到 Demo4 的实现已保留,可继续复用
- 当前需要自己完成 `demo5_full_project/` 的 RAG、SSE、模型调参和版本管理
- 通过本关后,就完成整套学习项目
- 完整答案仍然保留在 `main` 分支

## 学习入口

- 先读 [TODO.md](TODO.md)
- 卡住时看 [HINTS.md](HINTS.md)
- 易错点和排查看 [FAQ.md](FAQ.md)
- 提交前对照 [CHECKLIST.md](CHECKLIST.md)
- 完成后回看 [REFLECTION.md](REFLECTION.md)
- 再看 `demo5_full_project/tests/test_full_project.py`
- 当前关重点是 `main.py`、`rag/`、`ml/tuner.py`、`ml/model_io.py`

## 建议实现步骤

1. 先让 `health` 和 `Embedder` 跑起来。
2. 再补 `Retriever`。
3. 最后补 SSE 和模型调参/版本管理。

## 快速导航

- [在线演示](#在线演示)
Expand Down
13 changes: 13 additions & 0 deletions REFLECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Demo5 REFLECTION

学完这一关,你应该能说清楚这些事:

- 为什么完整 AI 项目不只是一个模型,还包括检索、流式输出和版本管理
- RAG、SSE、调参与模型存档分别解决什么问题
- 为什么“能工作的最小版本”比“堆很多技术名词”更重要
- 如何把一个学习项目收束成接近完整作品集的样子

如果你已经完成本关,说明你已经具备:

- 把 AI 项目从 demo 推进到完整展示版的能力
- 用一整套分阶段项目讲清自己工程思路的能力
33 changes: 33 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Demo5 TODO

当前目标:补齐完整项目版里的 RAG、SSE 和模型版本管理。

## 你需要实现的文件

- `demo5_full_project/main.py`
- `demo5_full_project/app/core/rag/embedder.py`
- `demo5_full_project/app/core/rag/retriever.py`
- `demo5_full_project/app/ml/tuner.py`
- `demo5_full_project/app/ml/model_io.py`

## 建议实现步骤

1. 先完成 `Embedder.encode()`,返回 1D numpy 向量。
2. 再完成 `Retriever.retrieve()`,确保返回列表。
3. 实现 `/health`,返回 `status` 和 `version`。
4. 实现 `/stream`,返回 `text/event-stream`。
5. 实现 `tune_model()`,哪怕先用一个很简单的搜索逻辑。
6. 实现 `save_model/load_model()`,并支持 metadata。

## 完成标准

- `pytest demo5_full_project/tests/test_full_project.py -q` 通过
- 推送到 `demo5-starter` 后,GitHub Actions 成功运行
- 这套学习项目全部完成

## 卡住时看哪里

- 已完成的 Demo1 到 Demo4
- 当前分支的 `README.md`
- `docs/demo_specs.md`
- 完整答案在 `main` 分支
23 changes: 4 additions & 19 deletions demo5_full_project/app/core/rag/embedder.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,6 @@
from __future__ import annotations

import hashlib

import numpy as np


class Embedder:
def __init__(self, dimension: int = 32):
self.dimension = dimension
"""Demo5 starter: convert text into a numeric vector."""

def encode(self, text: str) -> np.ndarray:
text = text or ""
buckets = np.zeros(self.dimension, dtype=float)
for token in text.encode("utf-8"):
buckets[token % self.dimension] += 1.0
digest = hashlib.sha256(text.encode("utf-8")).digest()
for idx, byte in enumerate(digest[: self.dimension]):
buckets[idx] += byte / 255.0
norm = np.linalg.norm(buckets)
return buckets if norm == 0 else buckets / norm
def encode(self, text):
"""TODO: return a 1D numpy array embedding."""
raise NotImplementedError("Implement Embedder.encode for Demo5")
34 changes: 6 additions & 28 deletions demo5_full_project/app/core/rag/retriever.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,13 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import List

import numpy as np

from app.core.rag.embedder import Embedder


@dataclass
class DocumentChunk:
text: str


class Retriever:
"""Demo5 starter: retrieve top-k relevant chunks from a tiny knowledge base."""

def __init__(self) -> None:
self.embedder = Embedder()
self._documents = [
DocumentChunk("Agent 是一种能够感知、决策并执行动作的软件实体。"),
DocumentChunk("RAG 会先检索知识,再把检索结果拼到生成提示词中。"),
DocumentChunk("SSE 适合服务端向客户端单向推送流式文本。"),
]
self._documents = []

def retrieve(self, query: str, top_k: int = 3) -> List[str]:
if not self._documents:
return []
query_vec = self.embedder.encode(query)
scored = []
for doc in self._documents:
doc_vec = self.embedder.encode(doc.text)
score = float(np.dot(query_vec, doc_vec))
scored.append((score, doc.text))
scored.sort(reverse=True, key=lambda item: item[0])
return [text for _, text in scored[: max(0, min(top_k, 5))]]
def retrieve(self, query, top_k=3):
"""TODO: return a list of retrieved text chunks."""
raise NotImplementedError("Implement Retriever.retrieve for Demo5")
28 changes: 6 additions & 22 deletions demo5_full_project/app/ml/model_io.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,8 @@
from __future__ import annotations
def save_model(model, path, metadata=None):
"""TODO: save a model and its metadata."""
raise NotImplementedError("Implement save_model for Demo5")

import json
from pathlib import Path

import joblib


def save_model(model, path: str, metadata=None) -> None:
path_obj = Path(path)
path_obj.parent.mkdir(parents=True, exist_ok=True)
joblib.dump(model, path_obj)
meta_path = path_obj.with_suffix(path_obj.suffix + ".meta.json")
meta_path.write_text(json.dumps(metadata or {}, ensure_ascii=False, indent=2), encoding="utf-8")


def load_model(path: str, return_metadata: bool = False):
path_obj = Path(path)
model = joblib.load(path_obj)
meta_path = path_obj.with_suffix(path_obj.suffix + ".meta.json")
metadata = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {}
if return_metadata:
return model, metadata
return model
def load_model(path, return_metadata=False):
"""TODO: load a model and optionally return metadata."""
raise NotImplementedError("Implement load_model for Demo5")
29 changes: 3 additions & 26 deletions demo5_full_project/app/ml/tuner.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,3 @@
from __future__ import annotations

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score


def tune_model(X_train, y_train, n_trials: int = 5):
candidate_depths = [2, 3, 4, 5, None]
candidate_estimators = [20, 50, 100, 150, 200]
best_model = None
best_score = -1.0

for idx in range(max(1, n_trials)):
model = RandomForestClassifier(
n_estimators=candidate_estimators[idx % len(candidate_estimators)],
max_depth=candidate_depths[idx % len(candidate_depths)],
random_state=42 + idx,
)
score = float(cross_val_score(model, X_train, y_train, cv=3).mean())
if score > best_score:
best_score = score
best_model = model

assert best_model is not None
best_model.fit(X_train, y_train)
return best_model, best_score
def tune_model(X_train, y_train, n_trials=5):
"""TODO: search for a better model configuration and return model + score."""
raise NotImplementedError("Implement tune_model for Demo5")
69 changes: 5 additions & 64 deletions demo5_full_project/main.py
Original file line number Diff line number Diff line change
@@ -1,75 +1,16 @@
from __future__ import annotations

import json
from typing import Iterator, List
from uuid import uuid4

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel


app = FastAPI(title="Demo5 Full Project")
TASKS = {}


class ChatRequest(BaseModel):
message: str


class TaskCreateRequest(BaseModel):
title: str
priority: str
due_date: str
app = FastAPI(title="Demo5 Starter")


@app.get("/health")
def health():
return {"status": "ok", "version": "1.0.0"}


@app.post("/chat")
def chat(payload: ChatRequest):
message = payload.message.strip()
tools_used: List[str] = []
if "任务" in message and any(token in message for token in ["列", "list", "所有"]):
tools_used.append("list_tasks")
reply = f"当前共有 {len(TASKS)} 个任务。"
else:
reply = "Demo5 已收到请求,支持任务、流式输出和检索模块演示。"
return {"reply": reply, "tools_used": tools_used}


@app.get("/tasks")
def get_tasks():
return {"tasks": list(TASKS.values())}


@app.post("/tasks", status_code=201)
def create_task(payload: TaskCreateRequest):
task = payload.model_dump()
task["id"] = str(uuid4())
TASKS[task["id"]] = task
return task


@app.delete("/tasks/{task_id}")
def delete_task(task_id: str):
if task_id not in TASKS:
from fastapi import HTTPException

raise HTTPException(status_code=404, detail="Task not found")
del TASKS[task_id]
return {"deleted": True}


def _sse_event_stream(message: str) -> Iterator[str]:
for chunk in ["收到消息", "正在处理", message]:
payload = json.dumps({"type": "token", "content": chunk}, ensure_ascii=False)
yield f"data: {payload}\n\n"
yield "data: {\"type\": \"done\"}\n\n"
"""TODO: return health information including version."""
raise NotImplementedError("Implement /health for Demo5")


@app.get("/stream")
def stream(message: str):
return StreamingResponse(_sse_event_stream(message), media_type="text/event-stream")
"""TODO: return an SSE stream for incremental output."""
raise NotImplementedError("Implement /stream for Demo5")