diff --git a/CHECKLIST.md b/CHECKLIST.md new file mode 100644 index 0000000..7e8a3bf --- /dev/null +++ b/CHECKLIST.md @@ -0,0 +1,15 @@ +# Demo4 CHECKLIST + +## 过关前自检 + +- [ ] `GET /health` 返回 200 +- [ ] `/health` 响应里有 `status: ok` +- [ ] `POST /chat` 返回 `reply` +- [ ] `POST /chat` 返回 `tools_used` +- [ ] `GET /tasks` 返回列表结构 +- [ ] `POST /tasks` 能创建任务并返回 `id` +- [ ] `DELETE /tasks/{id}` 能删除已创建任务 +- [ ] 删除不存在任务时返回 404 +- [ ] 缺少 `message` 字段时 `/chat` 返回 422 +- [ ] `pytest "demo4_综合项目/tests/test_app.py" -q` 通过 +- [ ] push 到 `demo4-starter` 后,Actions 通过 diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000..6e91e6d --- /dev/null +++ b/FAQ.md @@ -0,0 +1,30 @@ +# Demo4 FAQ + +## 1. 为什么这一关只有一个 `main.py`? + +因为这里先强调接口整合。 +把最小 Web 服务跑起来,比先做复杂目录拆分更重要。 + +## 2. `/chat` 的核心是什么? + +不是“回答得多智能”,而是能稳定返回约定结构: + +- `reply` +- `tools_used` + +## 3. 404 和 422 分别怎么理解? + +- 404:资源不存在,比如删除一个不存在的任务 +- 422:请求体不符合定义,比如缺少必填字段 + +## 4. 为什么 FastAPI 很适合这一关? + +因为它天然适合做: + +- 请求校验 +- JSON 接口 +- Swagger 文档展示 + +## 5. 怎么排查接口测试失败? + +先对照测试文件看响应结构,再检查状态码,最后看字段名是不是完全一致。 diff --git a/HINTS.md b/HINTS.md new file mode 100644 index 0000000..a7cae14 --- /dev/null +++ b/HINTS.md @@ -0,0 +1,22 @@ +# Demo4 HINTS + +只给思路,不给答案。 + +## 你可以先想清楚的点 + +- 这一关重点是 API 结构,不是页面美观 +- 测试已经把接口契约写出来了,按测试返回结构实现最稳 +- 先做“最小服务”,再考虑集成更多前面关卡能力 + +## 容易卡住的地方 + +- `/chat` 需要返回 `reply` 和 `tools_used` +- 删除不存在任务时要返回 404 +- 请求体验证失败时,FastAPI + Pydantic 会自动给 422,不需要你手写太多 + +## 实现顺序建议 + +1. 建 `app = FastAPI()` +2. 完成 `/health` +3. 完成 `/chat` +4. 完成任务接口 diff --git a/README.md b/README.md index 77b381d..f679363 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,31 @@ Agent · ML · FastAPI · RAG · SSE · CI Unlock Flow

+## 当前分支 + +你现在位于 `demo4-starter`。 + +- Demo1 到 Demo3 的实现已保留,可直接复用 +- 当前需要自己完成 `demo4_综合项目/` 的 FastAPI 应用集成 +- 通过本关后,push 到 `demo4-starter` 会自动解锁 Demo5 +- 完整答案仍然保留在 `main` 分支 + +## 学习入口 + +- 先读 [TODO.md](TODO.md) +- 卡住时看 [HINTS.md](HINTS.md) +- 易错点和排查看 [FAQ.md](FAQ.md) +- 提交前对照 [CHECKLIST.md](CHECKLIST.md) +- 完成后回看 [REFLECTION.md](REFLECTION.md) +- 再看 `demo4_综合项目/tests/test_app.py` +- 当前只需要关注 `demo4_综合项目/main.py` + +## 建议实现步骤 + +1. 先补 `/health`。 +2. 再补 `/chat`。 +3. 最后补任务相关接口和 404 处理。 + ## 快速导航 - [在线演示](#在线演示) diff --git a/REFLECTION.md b/REFLECTION.md new file mode 100644 index 0000000..cdad336 --- /dev/null +++ b/REFLECTION.md @@ -0,0 +1,13 @@ +# Demo4 REFLECTION + +学完这一关,你应该能说清楚这些事: + +- 如何把前面的 Agent/任务/ML 能力服务化 +- 为什么接口契约和返回结构在服务层很重要 +- FastAPI、Pydantic、测试三者如何配合 +- 为什么先做最小 API,再扩展前端和日志,是更稳的工程路径 + +如果你已经完成本关,说明你已经具备: + +- 搭建一个最小 AI 服务接口层的能力 +- 为完整项目版打基础的能力 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..9227c41 --- /dev/null +++ b/TODO.md @@ -0,0 +1,29 @@ +# Demo4 TODO + +当前目标:把前面的能力整合成一个 FastAPI 服务。 + +## 你需要实现的文件 + +- `demo4_综合项目/main.py` + +## 建议实现步骤 + +1. 先创建 `FastAPI()` 应用实例。 +2. 完成 `/health`,返回一个最小健康检查结果。 +3. 完成 `/chat`,至少返回 `reply` 和 `tools_used`。 +4. 完成 `/tasks` 的 GET 和 POST。 +5. 完成 `/tasks/{task_id}` 的 DELETE,并处理不存在任务时的 404。 +6. 跑测试确认响应结构符合预期。 + +## 完成标准 + +- `pytest "demo4_综合项目/tests/test_app.py" -q` 通过 +- 推送到 `demo4-starter` 后,GitHub Actions 成功运行 +- Issues 页面出现 “Demo5 已解锁” + +## 卡住时看哪里 + +- 已完成的 Demo1 到 Demo3 +- 当前分支的 `README.md` +- `docs/demo_specs.md` +- 完整答案在 `main` 分支 diff --git "a/demo4_\347\273\274\345\220\210\351\241\271\347\233\256/main.py" "b/demo4_\347\273\274\345\220\210\351\241\271\347\233\256/main.py" index 8612b7a..64b164c 100644 --- "a/demo4_\347\273\274\345\220\210\351\241\271\347\233\256/main.py" +++ "b/demo4_\347\273\274\345\220\210\351\241\271\347\233\256/main.py" @@ -1,75 +1,34 @@ -from __future__ import annotations +from fastapi import FastAPI -import json -from typing import Iterator, List -from uuid import uuid4 -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -from fastapi.responses import StreamingResponse - - -app = FastAPI(title="Demo4 Integrated App") -TASKS = {} - - -class ChatRequest(BaseModel): - message: str - - -class TaskCreateRequest(BaseModel): - title: str - priority: str - due_date: str +app = FastAPI(title="Demo4 Starter") @app.get("/health") def health(): - return {"status": "ok", "version": "0.4.0"} + """TODO: return a basic health response.""" + raise NotImplementedError("Implement /health for Demo4") @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)} 个任务。" - elif any(token in message for token in ["你好", "hi", "hello"]): - reply = "你好,这里是 Demo4 综合项目接口。" - else: - reply = "消息已收到,我可以处理聊天和任务相关请求。" - return {"reply": reply, "tools_used": tools_used} +def chat(): + """TODO: accept a message payload and return reply + tools_used.""" + raise NotImplementedError("Implement /chat for Demo4") @app.get("/tasks") def get_tasks(): - return {"tasks": list(TASKS.values())} + """TODO: return a task list.""" + raise NotImplementedError("Implement GET /tasks for Demo4") -@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.post("/tasks") +def create_task(): + """TODO: create a task object and return it.""" + raise NotImplementedError("Implement POST /tasks for Demo4") @app.delete("/tasks/{task_id}") def delete_task(task_id: str): - if task_id not in TASKS: - 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 ["demo4", "stream", message]: - payload = json.dumps({"type": "token", "content": chunk}, ensure_ascii=False) - yield f"data: {payload}\n\n" - yield "data: {\"type\": \"done\"}\n\n" - - -@app.get("/stream") -def stream(message: str): - return StreamingResponse(_sse_event_stream(message), media_type="text/event-stream") + """TODO: delete an existing task or return 404.""" + raise NotImplementedError("Implement DELETE /tasks/{task_id} for Demo4") diff --git a/demo5_full_project/app/__init__.py b/demo5_full_project/app/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/demo5_full_project/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/demo5_full_project/app/core/__init__.py b/demo5_full_project/app/core/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/demo5_full_project/app/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/demo5_full_project/app/core/rag/__init__.py b/demo5_full_project/app/core/rag/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/demo5_full_project/app/core/rag/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/demo5_full_project/app/core/rag/embedder.py b/demo5_full_project/app/core/rag/embedder.py deleted file mode 100644 index b4ee5dd..0000000 --- a/demo5_full_project/app/core/rag/embedder.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -import hashlib - -import numpy as np - - -class Embedder: - def __init__(self, dimension: int = 32): - self.dimension = dimension - - 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 diff --git a/demo5_full_project/app/core/rag/retriever.py b/demo5_full_project/app/core/rag/retriever.py deleted file mode 100644 index 66146da..0000000 --- a/demo5_full_project/app/core/rag/retriever.py +++ /dev/null @@ -1,35 +0,0 @@ -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: - def __init__(self) -> None: - self.embedder = Embedder() - self._documents = [ - DocumentChunk("Agent 是一种能够感知、决策并执行动作的软件实体。"), - DocumentChunk("RAG 会先检索知识,再把检索结果拼到生成提示词中。"), - DocumentChunk("SSE 适合服务端向客户端单向推送流式文本。"), - ] - - 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))]] diff --git a/demo5_full_project/app/ml/__init__.py b/demo5_full_project/app/ml/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/demo5_full_project/app/ml/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/demo5_full_project/app/ml/model_io.py b/demo5_full_project/app/ml/model_io.py deleted file mode 100644 index 11b195b..0000000 --- a/demo5_full_project/app/ml/model_io.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -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 diff --git a/demo5_full_project/app/ml/tuner.py b/demo5_full_project/app/ml/tuner.py deleted file mode 100644 index 3a71c66..0000000 --- a/demo5_full_project/app/ml/tuner.py +++ /dev/null @@ -1,26 +0,0 @@ -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 diff --git a/demo5_full_project/main.py b/demo5_full_project/main.py deleted file mode 100644 index 1918571..0000000 --- a/demo5_full_project/main.py +++ /dev/null @@ -1,75 +0,0 @@ -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.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" - - -@app.get("/stream") -def stream(message: str): - return StreamingResponse(_sse_event_stream(message), media_type="text/event-stream")