From 98c2ca9f9439370fc6d9425055e930fdfd900e2f Mon Sep 17 00:00:00 2001 From: aplicity <1634594707@qq.com> Date: Sat, 4 Apr 2026 22:16:26 +0800 Subject: [PATCH 1/4] chore: convert demo5-starter into staged starter --- README.md | 9 +++ demo5_full_project/app/core/rag/embedder.py | 23 ++----- demo5_full_project/app/core/rag/retriever.py | 34 ++-------- demo5_full_project/app/ml/model_io.py | 28 ++------ demo5_full_project/app/ml/tuner.py | 29 +------- demo5_full_project/main.py | 69 ++------------------ 6 files changed, 33 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index 77b381d..651f71d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,15 @@ Agent · ML · FastAPI · RAG · SSE · CI Unlock Flow

+## 当前分支 + +你现在位于 `demo5-starter`。 + +- Demo1 到 Demo4 的实现已保留,可继续复用 +- 当前需要自己完成 `demo5_full_project/` 的 RAG、SSE、模型调参和版本管理 +- 通过本关后,就完成整套学习项目 +- 完整答案仍然保留在 `main` 分支 + ## 快速导航 - [在线演示](#在线演示) diff --git a/demo5_full_project/app/core/rag/embedder.py b/demo5_full_project/app/core/rag/embedder.py index b4ee5dd..1c1c796 100644 --- a/demo5_full_project/app/core/rag/embedder.py +++ b/demo5_full_project/app/core/rag/embedder.py @@ -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") diff --git a/demo5_full_project/app/core/rag/retriever.py b/demo5_full_project/app/core/rag/retriever.py index 66146da..c5db047 100644 --- a/demo5_full_project/app/core/rag/retriever.py +++ b/demo5_full_project/app/core/rag/retriever.py @@ -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") diff --git a/demo5_full_project/app/ml/model_io.py b/demo5_full_project/app/ml/model_io.py index 11b195b..9a00909 100644 --- a/demo5_full_project/app/ml/model_io.py +++ b/demo5_full_project/app/ml/model_io.py @@ -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") diff --git a/demo5_full_project/app/ml/tuner.py b/demo5_full_project/app/ml/tuner.py index 3a71c66..4e80f65 100644 --- a/demo5_full_project/app/ml/tuner.py +++ b/demo5_full_project/app/ml/tuner.py @@ -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") diff --git a/demo5_full_project/main.py b/demo5_full_project/main.py index 1918571..81a05b2 100644 --- a/demo5_full_project/main.py +++ b/demo5_full_project/main.py @@ -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") From 5d0bf709c3a2a3dcee55404bd27361472cfa90d9 Mon Sep 17 00:00:00 2001 From: aplicity <1634594707@qq.com> Date: Sat, 4 Apr 2026 22:23:59 +0800 Subject: [PATCH 2/4] docs: add learner TODO guide for demo5-starter --- README.md | 12 ++++++++++++ TODO.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 TODO.md diff --git a/README.md b/README.md index 651f71d..3caf03f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,18 @@ - 通过本关后,就完成整套学习项目 - 完整答案仍然保留在 `main` 分支 +## 学习入口 + +- 先读 [TODO.md](TODO.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 和模型调参/版本管理。 + ## 快速导航 - [在线演示](#在线演示) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8b7f348 --- /dev/null +++ b/TODO.md @@ -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` 分支 From 7c8d8d92a92d143bc41e7fadc5f2b21cb5119939 Mon Sep 17 00:00:00 2001 From: aplicity <1634594707@qq.com> Date: Sat, 4 Apr 2026 22:34:43 +0800 Subject: [PATCH 3/4] docs: add hints and checklist for demo5-starter --- CHECKLIST.md | 15 +++++++++++++++ HINTS.md | 25 +++++++++++++++++++++++++ README.md | 2 ++ 3 files changed, 42 insertions(+) create mode 100644 CHECKLIST.md create mode 100644 HINTS.md diff --git a/CHECKLIST.md b/CHECKLIST.md new file mode 100644 index 0000000..12b71f3 --- /dev/null +++ b/CHECKLIST.md @@ -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 通过 diff --git a/HINTS.md b/HINTS.md new file mode 100644 index 0000000..64c67e8 --- /dev/null +++ b/HINTS.md @@ -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` diff --git a/README.md b/README.md index 3caf03f..c5be7c8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ ## 学习入口 - 先读 [TODO.md](TODO.md) +- 卡住时看 [HINTS.md](HINTS.md) +- 提交前对照 [CHECKLIST.md](CHECKLIST.md) - 再看 `demo5_full_project/tests/test_full_project.py` - 当前关重点是 `main.py`、`rag/`、`ml/tuner.py`、`ml/model_io.py` From 3fb0f088f41d3ae045bb1a8d44ad19a00bdd24db Mon Sep 17 00:00:00 2001 From: aplicity <1634594707@qq.com> Date: Sat, 4 Apr 2026 22:40:16 +0800 Subject: [PATCH 4/4] docs: add faq and reflection for demo5-starter --- FAQ.md | 33 +++++++++++++++++++++++++++++++++ README.md | 2 ++ REFLECTION.md | 13 +++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 FAQ.md create mode 100644 REFLECTION.md diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000..d91ff4d --- /dev/null +++ b/FAQ.md @@ -0,0 +1,33 @@ +# Demo5 FAQ + +## 1. 这一关一定要接真实大模型吗? + +不一定。 +测试重点是模块职责和接口行为,而不是外部依赖多完整。 + +## 2. RAG 一定要用真实向量数据库吗? + +不需要。 +先实现一个最小可工作的检索逻辑,能说明流程就够了。 + +## 3. `/stream` 为什么容易出问题? + +因为它不是普通 JSON 接口。 +重点要确认: + +- 路由存在 +- `content-type` 是 `text/event-stream` +- 输出格式符合事件流习惯 + +## 4. `tune_model()` 应该做到什么程度? + +先做到“返回一个模型和一个分数”。 +不必一开始就追求完整 Optuna 体验。 + +## 5. metadata 为什么重要? + +因为完整项目里不只是保存模型本体,还要知道: + +- 这是哪个版本 +- 用了什么数据 +- 达到了什么效果 diff --git a/README.md b/README.md index c5be7c8..beb1565 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ - 先读 [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` diff --git a/REFLECTION.md b/REFLECTION.md new file mode 100644 index 0000000..b93eeb8 --- /dev/null +++ b/REFLECTION.md @@ -0,0 +1,13 @@ +# Demo5 REFLECTION + +学完这一关,你应该能说清楚这些事: + +- 为什么完整 AI 项目不只是一个模型,还包括检索、流式输出和版本管理 +- RAG、SSE、调参与模型存档分别解决什么问题 +- 为什么“能工作的最小版本”比“堆很多技术名词”更重要 +- 如何把一个学习项目收束成接近完整作品集的样子 + +如果你已经完成本关,说明你已经具备: + +- 把 AI 项目从 demo 推进到完整展示版的能力 +- 用一整套分阶段项目讲清自己工程思路的能力