diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8012b2455..47550cffb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -98,16 +98,6 @@ jobs: pip install -r requirements/server.txt pip install pytest pytest-asyncio - - name: Create minimal runtime config - run: | - mkdir -p data/user/settings - cat > data/user/settings/main.yaml <<'YAML' - system: - language: en - logging: - level: WARNING - YAML - - name: Run smoke tests run: | echo "🧪 Running smoke test subset" diff --git a/Dockerfile b/Dockerfile index 4991652f5..1df8d5896 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,10 +15,7 @@ # ============================================ # Stage 1: Frontend Builder # ============================================ -# Run on the build platform natively (not under QEMU emulation). -# The output is platform-independent static assets (JS/HTML/CSS), -# so there is no need to cross-compile this stage. -FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-builder +FROM node:22-slim AS frontend-builder WORKDIR /app/web @@ -28,10 +25,8 @@ ARG BACKEND_PORT=8001 # Copy package files first for better caching COPY web/package.json web/package-lock.json* ./ -# Install dependencies with generous timeout for CI environments -RUN npm config set fetch-timeout 600000 && \ - npm config set fetch-retries 5 && \ - npm ci --legacy-peer-deps +# Install dependencies +RUN npm ci --legacy-peer-deps # Copy frontend source code COPY web/ ./ @@ -44,14 +39,6 @@ RUN echo "NEXT_PUBLIC_API_BASE=__NEXT_PUBLIC_API_BASE_PLACEHOLDER__" > .env.loca # This allows runtime environment variable injection RUN npm run build -# ============================================ -# Stage 1b: Node Runtime for Target Platform -# ============================================ -# Provides the correctly-architected node binary for the final image. -# Unlike frontend-builder (pinned to BUILDPLATFORM), this stage pulls -# the node image matching each target platform (amd64 / arm64). -FROM node:22-slim AS node-runtime - # ============================================ # Stage 2: Python Base with Dependencies # ============================================ @@ -127,9 +114,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libxrender1 \ && rm -rf /var/lib/apt/lists/* -# Copy Node.js from node-runtime stage (platform-matched binary) -COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node -COPY --from=node-runtime /usr/local/lib/node_modules /usr/local/lib/node_modules +# Copy Node.js from frontend-builder stage (avoids re-downloading from NodeSource) +COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node +COPY --from=frontend-builder /usr/local/lib/node_modules /usr/local/lib/node_modules RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ && ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \ && node --version && npm --version diff --git a/README.md b/README.md index 9c15f1c64..5587a15e9 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ # DeepTutor: Agent-Native Personalized Tutoring -HKUDS%2FDeepTutor | Trendshift - [![Python 3.11+](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) [![Next.js 16](https://img.shields.io/badge/Next.js-16-000000?style=flat-square&logo=next.js&logoColor=white)](https://nextjs.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](LICENSE) @@ -35,17 +33,9 @@ ### 📦 Releases -> **[2026.4.11]** [v1.0.2](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.2) — Search consolidation simplification with SearXNG fallback, provider switch fix, explicit runtime config in test runner, and frontend resource leak fixes. - -> **[2026.4.10]** [v1.0.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.1) — New Visualize capability with Chart.js/SVG rendering pipeline, quiz duplicate prevention with generation history, o4-mini model support, and server logging improvements. - -> **[2026.4.10]** [v1.0.0-beta.4](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.4) — Embedding progress tracking with HTTP 429 rate limit retry, cross-platform start tour dependency management, and case-insensitive MIME validation fix. - -> **[2026.4.8]** [v1.0.0-beta.3](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.3) — Remove litellm dependency with native OpenAI/Anthropic SDK providers, Windows Math Animator compatibility, robust JSON parsing for LLM outputs, Guided Learning KaTeX & navigation fixes, and full i18n coverage for Chinses. - > **[2026.4.7]** [v1.0.0-beta.2](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.2) — Runtime cache invalidation for hot settings reload, MinerU nested output support, mimic WebSocket fix, Python 3.11+ minimum, and CI improvements. -> **[2026.4.4]** [v1.0.0-beta.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.1) — Agent-native architecture rewrite (~200k lines) with two-layer plugin model (Tools + Capabilities), CLI & SDK entry points, TutorBot multi-channel bot agent, Co-Writer, Guided Learning, and persistent memory. +> **[2026.4.4]** [v1.0.0-beta.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.1) — Agent-native architecture rewrite (DeepTutor 2.0) with two-layer plugin model (Tools + Capabilities), CLI & SDK entry points, TutorBot multi-channel bot agent, Co-Writer, Guided Learning, and persistent memory.
Past releases @@ -141,71 +131,6 @@ EMBEDDING_HOST=https://api.openai.com/v1 EMBEDDING_DIMENSION=3072 ``` -
-Supported LLM Providers - -| Provider | Binding | Default Base URL | -|:--|:--|:--| -| AiHubMix | `aihubmix` | `https://aihubmix.com/v1` | -| Anthropic | `anthropic` | `https://api.anthropic.com/v1` | -| Azure OpenAI | `azure_openai` | — | -| BytePlus | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | -| BytePlus Coding Plan | `byteplus_coding_plan` | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | -| Custom (OpenAI-compat) | `custom` | — | -| DashScope (Qwen) | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | -| DeepSeek | `deepseek` | `https://api.deepseek.com` | -| Gemini | `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai/` | -| GitHub Copilot | `github_copilot` | `https://api.githubcopilot.com` | -| Groq | `groq` | `https://api.groq.com/openai/v1` | -| MiniMax | `minimax` | `https://api.minimax.io/v1` | -| Mistral | `mistral` | `https://api.mistral.ai/v1` | -| Moonshot (Kimi) | `moonshot` | `https://api.moonshot.ai/v1` | -| Ollama | `ollama` | `http://localhost:11434/v1` | -| OpenAI | `openai` | `https://api.openai.com/v1` | -| OpenAI Codex | `openai_codex` | `https://chatgpt.com/backend-api` | -| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | -| OpenVINO Model Server | `ovms` | `http://localhost:8000/v3` | -| Qianfan (Ernie) | `qianfan` | `https://qianfan.baidubce.com/v2` | -| SiliconFlow | `siliconflow` | `https://api.siliconflow.cn/v1` | -| Step Fun | `stepfun` | `https://api.stepfun.com/v1` | -| vLLM | `vllm` | `http://localhost:8000/v1` | -| VolcEngine | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | -| VolcEngine Coding Plan | `volcengine_coding_plan` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| Xiaomi MIMO | `xiaomi_mimo` | `https://api.xiaomimimo.com/v1` | -| Zhipu AI (GLM) | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | - -
- -
-Supported Embedding Providers - -Embedding uses the same provider list as LLM. Common choices: - -| Provider | Binding | Model Example | -|:--|:--|:--| -| OpenAI | `openai` | `text-embedding-3-large` | -| DashScope | `dashscope` | `text-embedding-v3` | -| Ollama | `ollama` | `nomic-embed-text` | -| SiliconFlow | `siliconflow` | `BAAI/bge-m3` | -| vLLM | `vllm` | Any embedding model | -| Any OpenAI-compatible | `custom` | — | - -
- -
-Supported Web Search Providers - -| Provider | Env Key | Notes | -|:--|:--|:--| -| Brave | `BRAVE_API_KEY` | Recommended, free tier available | -| Tavily | `TAVILY_API_KEY` | | -| Jina | `JINA_API_KEY` | | -| SearXNG | — | Self-hosted, no API key needed | -| DuckDuckGo | — | No API key needed | -| Perplexity | `PERPLEXITY_API_KEY` | Requires API key | - -
- **3. Start services** ```bash @@ -589,7 +514,7 @@ deeptutor session open # Resume in REPL | `deeptutor config show` | Print current configuration summary | | `deeptutor plugin list` | List registered tools and capabilities | | `deeptutor plugin info ` | Show tool or capability details | -| `deeptutor provider login ` | Provider auth (`openai-codex` OAuth login; `github-copilot` validates an existing Copilot auth session) | +| `deeptutor provider login ` | OAuth login (`openai-codex`, `github-copilot`) |
diff --git a/assets/README/README_AR.md b/assets/README/README_AR.md index e30ef91c1..66bb72c68 100644 --- a/assets/README/README_AR.md +++ b/assets/README/README_AR.md @@ -2,21 +2,19 @@ DeepTutor -# DeepTutor: تعليم شخصي أصلي قائم على الوكلاء - -HKUDS%2FDeepTutor | Trendshift +# DeepTutor: نحو تعليم شخصي قائم على الوكلاء [![Python 3.11+](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) [![Next.js 16](https://img.shields.io/badge/Next.js-16-000000?style=flat-square&logo=next.js&logoColor=white)](https://nextjs.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](../../LICENSE) [![GitHub release](https://img.shields.io/github/v/release/HKUDS/DeepTutor?style=flat-square&color=brightgreen)](https://github.com/HKUDS/DeepTutor/releases) -[![arXiv](https://img.shields.io/badge/arXiv-Coming_Soon-b31b1b?style=flat-square&logo=arxiv&logoColor=white)](#) +[![GitHub last commit](https://img.shields.io/github/last-commit/HKUDS/DeepTutor?style=flat-square)](https://github.com/HKUDS/DeepTutor/commits) [![Discord](https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/eRsjPgMU4t) [![Feishu](https://img.shields.io/badge/Feishu-Group-00D4AA?style=flat-square&logo=feishu&logoColor=white)](../../Communication.md) [![WeChat](https://img.shields.io/badge/WeChat-Group-07C160?style=flat-square&logo=wechat&logoColor=white)](https://github.com/HKUDS/DeepTutor/issues/78) -[الميزات](#key-features) · [البدء](#get-started) · [استكشاف](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [خارطة الطريق](#roadmap) · [المجتمع](#community) +[الميزات](#key-features) · [البدء](#get-started) · [استكشاف](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [المجتمع](#community) [🇬🇧 English](../../README.md) · [🇨🇳 中文](README_CN.md) · [🇯🇵 日本語](README_JA.md) · [🇪🇸 Español](README_ES.md) · [🇫🇷 Français](README_FR.md) · [🇷🇺 Русский](README_RU.md) · [🇮🇳 हिन्दी](README_HI.md) · [🇵🇹 Português](README_PT.md) @@ -25,7 +23,7 @@ --- ### 📰 الأخبار -> **[2026.4.4]** منذ زمن غائبين! ✨ DeepTutor v1.0.0 وصل أخيرًا — تطور أصلي للوكلاء مع إعادة بناء المعمار من الصفر وTutorBot وأوضاع مرنة بموجب Apache-2.0. فصل جديد يبدأ! +> **[2026.3.24]** DeepTutor v1.0.0 ✨ — تطور أصلي للوكلاء: إعادة هيكلة خفيفة، TutorBot، أوضاع مرنة بموجب Apache-2.0. > **[2026.2.6]** 🚀 10k نجوم في 39 يومًا — شكرًا للمجتمع! @@ -35,9 +33,7 @@ ### 📦 الإصدارات -> **[2026.4.7]** [v1.0.0-beta.2](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.2) — إبطال ذاكرة التخزين المؤقت أثناء التشغيل لإعادة تحميل الإعدادات الساخنة، دعم مخرجات MinerU المتداخلة، إصلاح mimic WebSocket، الحد الأدنى Python 3.11+، وتحسينات CI. - -> **[2026.4.4]** [v1.0.0-beta.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.1) — إعادة كتابة أصلية للمعمار (DeepTutor 2.0): نموذج إضافات بطبقتين (Tools + Capabilities)، مداخل CLI وSDK، TutorBot متعدد القنوات، Co-Writer، تعليم موجّه، وذاكرة دائمة. +> **[2026.3.24]** [v1.0.0](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0) — إعادة هيكلة أصلية للوكلاء، تكامل مرن للأدوات، مداخل CLI وSDK، TutorBot بمحرك nanobot، Co-Writer، تعليم موجّه، ذاكرة دائمة.
إصدارات سابقة @@ -121,71 +117,6 @@ EMBEDDING_HOST=https://api.openai.com/v1 EMBEDDING_DIMENSION=3072 ``` -
-مزوّدو LLM المدعومون - -| المزوّد | Binding | عنوان Base الافتراضي | -|:--|:--|:--| -| AiHubMix | `aihubmix` | `https://aihubmix.com/v1` | -| Anthropic | `anthropic` | `https://api.anthropic.com/v1` | -| Azure OpenAI | `azure_openai` | — | -| BytePlus | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | -| BytePlus Coding Plan | `byteplus_coding_plan` | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | -| Custom (OpenAI-compat) | `custom` | — | -| DashScope (Qwen) | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | -| DeepSeek | `deepseek` | `https://api.deepseek.com` | -| Gemini | `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai/` | -| GitHub Copilot | `github_copilot` | `https://api.githubcopilot.com` | -| Groq | `groq` | `https://api.groq.com/openai/v1` | -| MiniMax | `minimax` | `https://api.minimax.io/v1` | -| Mistral | `mistral` | `https://api.mistral.ai/v1` | -| Moonshot (Kimi) | `moonshot` | `https://api.moonshot.ai/v1` | -| Ollama | `ollama` | `http://localhost:11434/v1` | -| OpenAI | `openai` | `https://api.openai.com/v1` | -| OpenAI Codex | `openai_codex` | `https://chatgpt.com/backend-api` | -| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | -| OpenVINO Model Server | `ovms` | `http://localhost:8000/v3` | -| Qianfan (Ernie) | `qianfan` | `https://qianfan.baidubce.com/v2` | -| SiliconFlow | `siliconflow` | `https://api.siliconflow.cn/v1` | -| Step Fun | `stepfun` | `https://api.stepfun.com/v1` | -| vLLM | `vllm` | `http://localhost:8000/v1` | -| VolcEngine | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | -| VolcEngine Coding Plan | `volcengine_coding_plan` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| Xiaomi MIMO | `xiaomi_mimo` | `https://api.xiaomimimo.com/v1` | -| Zhipu AI (GLM) | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | - -
- -
-مزوّدو التضمين المدعومون - -التضمين يستخدم نفس قائمة LLM. أمثلة شائعة: - -| المزوّد | Binding | مثال نموذج | -|:--|:--|:--| -| OpenAI | `openai` | `text-embedding-3-large` | -| DashScope | `dashscope` | `text-embedding-v3` | -| Ollama | `ollama` | `nomic-embed-text` | -| SiliconFlow | `siliconflow` | `BAAI/bge-m3` | -| vLLM | `vllm` | أي نموذج تضمين | -| متوافق OpenAI | `custom` | — | - -
- -
-مزوّدو البحث على الويب المدعومون - -| المزوّد | مفتاح البيئة | ملاحظات | -|:--|:--|:--| -| Brave | `BRAVE_API_KEY` | موصى به، يوجد مستوى مجاني | -| Tavily | `TAVILY_API_KEY` | | -| Jina | `JINA_API_KEY` | | -| SearXNG | — | مستضاف ذاتيًا، بلا مفتاح API | -| DuckDuckGo | — | بلا مفتاح API | -| Perplexity | `PERPLEXITY_API_KEY` | يتطلب مفتاح API | - -
- ```bash python -m deeptutor.api.run_server cd web && npm run dev -- -p 3782 @@ -407,7 +338,6 @@ deeptutor run deep_research "Attention mechanisms in transformers" ```bash deeptutor chat --capability deep_solve --kb my-kb -# داخل REPL: /cap و /tool و /kb و /history و /notebook و /config للتبديل فورًا ``` ```bash @@ -430,88 +360,16 @@ deeptutor session open
مرجع أوامر CLI الكامل -**المستوى الأعلى** - -| الأمر | الوصف | -|:---|:---| -| `deeptutor run ` | تشغيل قدرة في دور واحد (`chat`، `deep_solve`، `deep_question`، `deep_research`، `math_animator`) | -| `deeptutor chat` | REPL تفاعلي مع `--capability` و`--tool` و`--kb` و`--language` وغيرها | -| `deeptutor serve` | تشغيل خادم API الخاص بـ DeepTutor | - -**`deeptutor bot`** - -| الأمر | الوصف | -|:---|:---| -| `deeptutor bot list` | عرض جميع مثيلات TutorBot | -| `deeptutor bot create ` | إنشاء وتشغيل بوت (`--name`، `--persona`، `--model`) | -| `deeptutor bot start ` | تشغيل بوت | -| `deeptutor bot stop ` | إيقاف بوت | - -**`deeptutor kb`** - -| الأمر | الوصف | -|:---|:---| -| `deeptutor kb list` | قائمة قواعد المعرفة | -| `deeptutor kb info ` | تفاصيل قاعدة | -| `deeptutor kb create ` | إنشاء من مستندات (`--doc`، `--docs-dir`) | -| `deeptutor kb add ` | إضافة مستندات | -| `deeptutor kb search ` | بحث في القاعدة | -| `deeptutor kb set-default ` | تعيين KB افتراضية | -| `deeptutor kb delete ` | حذف (`--force`) | - -**`deeptutor memory`** - -| الأمر | الوصف | -|:---|:---| -| `deeptutor memory show [file]` | عرض (`summary`، `profile`، `all`) | -| `deeptutor memory clear [file]` | مسح (`--force`) | - -**`deeptutor session`** - -| الأمر | الوصف | -|:---|:---| -| `deeptutor session list` | قائمة الجلسات (`--limit`) | -| `deeptutor session show ` | رسائل الجلسة | -| `deeptutor session open ` | استئناف في REPL | -| `deeptutor session rename ` | إعادة تسمية (`--title`) | -| `deeptutor session delete ` | حذف | - -**`deeptutor notebook`** - | الأمر | الوصف | |:---|:---| -| `deeptutor notebook list` | قائمة الدفاتر | -| `deeptutor notebook create ` | إنشاء (`--description`) | -| `deeptutor notebook show ` | عرض السجلات | -| `deeptutor notebook add-md ` | استيراد Markdown | -| `deeptutor notebook replace-md ` | استبدال سجل | -| `deeptutor notebook remove-record ` | إزالة سجل | - -**`deeptutor config` / `plugin` / `provider`** +| `deeptutor run ` | جولة واحدة | +| `deeptutor chat` | REPL | +| `deeptutor serve` | خادم API | -| الأمر | الوصف | -|:---|:---| -| `deeptutor config show` | ملخص الإعدادات | -| `deeptutor plugin list` | الأدوات والقدرات المسجّلة | -| `deeptutor plugin info ` | تفاصيل أداة أو قدرة | -| `deeptutor provider login ` | تسجيل OAuth (`openai-codex`، `github-copilot`) | +**bot**، **kb**، **memory**، **session**، **notebook**، **config / plugin / provider** — كما في README الإنجليزي.
- -## 🗺️ خارطة الطريق - -| الحالة | مرحلة | -|:---:|:---| -| 🔜 | **المصادقة وتسجيل الدخول** — صفحة دخول اختيارية للنشر العام مع دعم متعدد المستخدمين | -| 🔜 | **السمات والمظهر** — سمات متنوعة وتخصيص واجهة المستخدم | -| 🔜 | **دمج LightRAG** — دمج [LightRAG](https://github.com/HKUDS/LightRAG) كمحرك متقدم لقواعد المعرفة | -| 🔜 | **موقع التوثيق** — توثيق كامل مع أدلة ومرجع API ودروس | - -> إذا كان DeepTutor مفيدًا لك، [امنحنا نجمة](https://github.com/HKUDS/DeepTutor/stargazers) — يدعمنا ذلك للاستمرار! - ---- - ## 🌐 المجتمع والنظام البيئي @@ -528,9 +386,6 @@ deeptutor session open ## 🤝 المساهمة
- -نأمل أن يكون DeepTutor هدية للمجتمع. 🎁 - Contributors diff --git a/assets/README/README_CN.md b/assets/README/README_CN.md index deb2f6c6e..c812fcf2f 100644 --- a/assets/README/README_CN.md +++ b/assets/README/README_CN.md @@ -2,21 +2,19 @@ DeepTutor -# DeepTutor:智能体原生的个性化辅导 - -HKUDS%2FDeepTutor | Trendshift +# DeepTutor:迈向智能体驱动的个性化辅导 [![Python 3.11+](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) [![Next.js 16](https://img.shields.io/badge/Next.js-16-000000?style=flat-square&logo=next.js&logoColor=white)](https://nextjs.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](../../LICENSE) [![GitHub release](https://img.shields.io/github/v/release/HKUDS/DeepTutor?style=flat-square&color=brightgreen)](https://github.com/HKUDS/DeepTutor/releases) -[![arXiv](https://img.shields.io/badge/arXiv-Coming_Soon-b31b1b?style=flat-square&logo=arxiv&logoColor=white)](#) +[![GitHub last commit](https://img.shields.io/github/last-commit/HKUDS/DeepTutor?style=flat-square)](https://github.com/HKUDS/DeepTutor/commits) [![Discord](https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/eRsjPgMU4t) [![Feishu](https://img.shields.io/badge/Feishu-Group-00D4AA?style=flat-square&logo=feishu&logoColor=white)](../../Communication.md) [![WeChat](https://img.shields.io/badge/WeChat-Group-07C160?style=flat-square&logo=wechat&logoColor=white)](https://github.com/HKUDS/DeepTutor/issues/78) -[核心亮点](#key-features) · [快速开始](#get-started) · [探索 DeepTutor](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [路线图](#roadmap) · [社区](#community) +[核心亮点](#key-features) · [快速开始](#get-started) · [探索 DeepTutor](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [社区](#community) [🇬🇧 English](../../README.md) · [🇯🇵 日本語](README_JA.md) · [🇪🇸 Español](README_ES.md) · [🇫🇷 Français](README_FR.md) · [🇸🇦 العربية](README_AR.md) · [🇷🇺 Русский](README_RU.md) · [🇮🇳 हिन्दी](README_HI.md) · [🇵🇹 Português](README_PT.md) @@ -25,7 +23,7 @@ --- ### 📰 动态 -> **[2026.4.4]** 好久不见!✨ DeepTutor v1.0.0 终于到来 —— 在 Apache-2.0 许可下的智能体原生演进:自底向上架构重写、TutorBot、灵活模式切换。新篇章开启,故事继续! +> **[2026.3.24]** 久等了!✨ DeepTutor v1.0.0 正式发布 —— 在 Apache-2.0 许可下的智能体原生演进:轻量重构、TutorBot、灵活模式切换。新篇章开启,故事继续! > **[2026.2.6]** 🚀 仅用 39 天即突破 10k star!感谢社区的大力支持! @@ -35,9 +33,7 @@ ### 📦 版本发布 -> **[2026.4.7]** [v1.0.0-beta.2](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.2) — 运行时缓存失效以支持热更新设置、MinerU 嵌套输出、mimic WebSocket 修复、最低 Python 3.11+,以及 CI 改进。 - -> **[2026.4.4]** [v1.0.0-beta.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.1) — 智能体原生架构重写(DeepTutor 2.0):双层插件模型(Tools + Capabilities)、CLI 与 SDK 入口、TutorBot 多渠道机器人、Co-Writer、引导式学习与持久记忆。 +> **[2026.3.24]** [v1.0.0](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0) — 智能体原生重构、灵活工具集成、CLI 与 SDK 入口、基于 nanobot 的 TutorBot、Co-Writer、引导式学习与持久记忆。更轻、更快、更好用!
历史版本 @@ -136,71 +132,6 @@ EMBEDDING_HOST=https://api.openai.com/v1 EMBEDDING_DIMENSION=3072 ``` -
-支持的 LLM 提供商 - -| 提供商 | Binding | 默认 Base URL | -|:--|:--|:--| -| AiHubMix | `aihubmix` | `https://aihubmix.com/v1` | -| Anthropic | `anthropic` | `https://api.anthropic.com/v1` | -| Azure OpenAI | `azure_openai` | — | -| BytePlus | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | -| BytePlus Coding Plan | `byteplus_coding_plan` | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | -| Custom (OpenAI-compat) | `custom` | — | -| DashScope (Qwen) | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | -| DeepSeek | `deepseek` | `https://api.deepseek.com` | -| Gemini | `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai/` | -| GitHub Copilot | `github_copilot` | `https://api.githubcopilot.com` | -| Groq | `groq` | `https://api.groq.com/openai/v1` | -| MiniMax | `minimax` | `https://api.minimax.io/v1` | -| Mistral | `mistral` | `https://api.mistral.ai/v1` | -| Moonshot (Kimi) | `moonshot` | `https://api.moonshot.ai/v1` | -| Ollama | `ollama` | `http://localhost:11434/v1` | -| OpenAI | `openai` | `https://api.openai.com/v1` | -| OpenAI Codex | `openai_codex` | `https://chatgpt.com/backend-api` | -| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | -| OpenVINO Model Server | `ovms` | `http://localhost:8000/v3` | -| Qianfan (Ernie) | `qianfan` | `https://qianfan.baidubce.com/v2` | -| SiliconFlow | `siliconflow` | `https://api.siliconflow.cn/v1` | -| Step Fun | `stepfun` | `https://api.stepfun.com/v1` | -| vLLM | `vllm` | `http://localhost:8000/v1` | -| VolcEngine | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | -| VolcEngine Coding Plan | `volcengine_coding_plan` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| Xiaomi MIMO | `xiaomi_mimo` | `https://api.xiaomimimo.com/v1` | -| Zhipu AI (GLM) | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | - -
- -
-支持的嵌入(Embedding)提供商 - -嵌入使用与 LLM 相同的提供商列表。常见选择: - -| 提供商 | Binding | 模型示例 | -|:--|:--|:--| -| OpenAI | `openai` | `text-embedding-3-large` | -| DashScope | `dashscope` | `text-embedding-v3` | -| Ollama | `ollama` | `nomic-embed-text` | -| SiliconFlow | `siliconflow` | `BAAI/bge-m3` | -| vLLM | `vllm` | 任意嵌入模型 | -| 任意 OpenAI 兼容 | `custom` | — | - -
- -
-支持的联网搜索提供商 - -| 提供商 | 环境变量键 | 说明 | -|:--|:--|:--| -| Brave | `BRAVE_API_KEY` | 推荐,有免费额度 | -| Tavily | `TAVILY_API_KEY` | | -| Jina | `JINA_API_KEY` | | -| SearXNG | — | 自托管,无需 API Key | -| DuckDuckGo | — | 无需 API Key | -| Perplexity | `PERPLEXITY_API_KEY` | 需要 API Key | - -
- **3. 启动服务** ```bash @@ -493,7 +424,7 @@ deeptutor run deep_research "Attention mechanisms in transformers" ```bash deeptutor chat --capability deep_solve --kb my-kb -# 在 REPL 内:/cap、/tool、/kb、/history、/notebook、/config 可随时切换 +# REPL 内:/cap、/tool、/kb、/history、/notebook、/config 等即时切换 ``` **知识库闭环** — 终端完成建库、追加与检索: @@ -590,20 +521,6 @@ deeptutor session open
- -## 🗺️ 路线图 - -| 状态 | 里程碑 | -|:---:|:---| -| 🔜 | **身份认证与登录** — 面向公网部署的可选登录页与多用户支持 | -| 🔜 | **主题与外观** — 多种主题与可定制界面 | -| 🔜 | **LightRAG 集成** — 将 [LightRAG](https://github.com/HKUDS/LightRAG) 作为高阶知识库引擎接入 | -| 🔜 | **文档站点** — 含指南、API 参考与教程的完整文档站 | - -> 若 DeepTutor 对你有帮助,欢迎 [点亮 Star](https://github.com/HKUDS/DeepTutor/stargazers),这对我们是很大的鼓励! - ---- - ## 🌐 社区与生态 diff --git a/assets/README/README_ES.md b/assets/README/README_ES.md index be72ce939..61508139f 100644 --- a/assets/README/README_ES.md +++ b/assets/README/README_ES.md @@ -2,21 +2,19 @@ DeepTutor -# DeepTutor: tutoría personalizada nativa para agentes - -HKUDS%2FDeepTutor | Trendshift +# DeepTutor: Hacia un tutorizado personalizado basado en agentes [![Python 3.11+](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) [![Next.js 16](https://img.shields.io/badge/Next.js-16-000000?style=flat-square&logo=next.js&logoColor=white)](https://nextjs.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](../../LICENSE) [![GitHub release](https://img.shields.io/github/v/release/HKUDS/DeepTutor?style=flat-square&color=brightgreen)](https://github.com/HKUDS/DeepTutor/releases) -[![arXiv](https://img.shields.io/badge/arXiv-Coming_Soon-b31b1b?style=flat-square&logo=arxiv&logoColor=white)](#) +[![GitHub last commit](https://img.shields.io/github/last-commit/HKUDS/DeepTutor?style=flat-square)](https://github.com/HKUDS/DeepTutor/commits) [![Discord](https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/eRsjPgMU4t) [![Feishu](https://img.shields.io/badge/Feishu-Group-00D4AA?style=flat-square&logo=feishu&logoColor=white)](../../Communication.md) [![WeChat](https://img.shields.io/badge/WeChat-Group-07C160?style=flat-square&logo=wechat&logoColor=white)](https://github.com/HKUDS/DeepTutor/issues/78) -[Funciones](#key-features) · [Primeros pasos](#get-started) · [Explorar](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [Hoja de ruta](#roadmap) · [Comunidad](#community) +[Funciones](#key-features) · [Primeros pasos](#get-started) · [Explorar](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [Comunidad](#community) [🇬🇧 English](../../README.md) · [🇨🇳 中文](README_CN.md) · [🇯🇵 日本語](README_JA.md) · [🇫🇷 Français](README_FR.md) · [🇸🇦 العربية](README_AR.md) · [🇷🇺 Русский](README_RU.md) · [🇮🇳 हिन्दी](README_HI.md) · [🇵🇹 Português](README_PT.md) @@ -25,7 +23,7 @@ --- ### 📰 Noticias -> **[2026.4.4]** ¡Cuánto tiempo! ✨ DeepTutor v1.0.0 ya está aquí: evolución nativa de agentes con reescritura de arquitectura desde cero, TutorBot y modos flexibles bajo Apache-2.0. ¡Un nuevo capítulo comienza! +> **[2026.3.24]** ¡DeepTutor v1.0.0 ya está aquí! ✨ Evolución nativa de agentes: refactor ligero, TutorBot y modos flexibles bajo Apache-2.0. > **[2026.2.6]** 🚀 ¡10k estrellas en solo 39 días! Gracias a la comunidad. @@ -35,9 +33,7 @@ ### 📦 Lanzamientos -> **[2026.4.7]** [v1.0.0-beta.2](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.2) — Invalidación de caché en tiempo de ejecución para recarga en caliente de ajustes, salida anidada de MinerU, corrección del WebSocket mimic, mínimo Python 3.11+ y mejoras de CI. - -> **[2026.4.4]** [v1.0.0-beta.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.1) — Reescritura nativa de agentes (DeepTutor 2.0): modelo de plugins en dos capas (Tools + Capabilities), entradas CLI y SDK, TutorBot multicanal, Co-Writer, aprendizaje guiado y memoria persistente. +> **[2026.3.24]** [v1.0.0](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0) — Refactor nativo de agentes, integración flexible de herramientas, entradas CLI y SDK, TutorBot con nanobot, Co-Writer, aprendizaje guiado y memoria persistente.
Lanzamientos anteriores @@ -121,71 +117,6 @@ EMBEDDING_HOST=https://api.openai.com/v1 EMBEDDING_DIMENSION=3072 ``` -
-Proveedores LLM admitidos - -| Proveedor | Binding | URL base predeterminada | -|:--|:--|:--| -| AiHubMix | `aihubmix` | `https://aihubmix.com/v1` | -| Anthropic | `anthropic` | `https://api.anthropic.com/v1` | -| Azure OpenAI | `azure_openai` | — | -| BytePlus | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | -| BytePlus Coding Plan | `byteplus_coding_plan` | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | -| Custom (OpenAI-compat) | `custom` | — | -| DashScope (Qwen) | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | -| DeepSeek | `deepseek` | `https://api.deepseek.com` | -| Gemini | `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai/` | -| GitHub Copilot | `github_copilot` | `https://api.githubcopilot.com` | -| Groq | `groq` | `https://api.groq.com/openai/v1` | -| MiniMax | `minimax` | `https://api.minimax.io/v1` | -| Mistral | `mistral` | `https://api.mistral.ai/v1` | -| Moonshot (Kimi) | `moonshot` | `https://api.moonshot.ai/v1` | -| Ollama | `ollama` | `http://localhost:11434/v1` | -| OpenAI | `openai` | `https://api.openai.com/v1` | -| OpenAI Codex | `openai_codex` | `https://chatgpt.com/backend-api` | -| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | -| OpenVINO Model Server | `ovms` | `http://localhost:8000/v3` | -| Qianfan (Ernie) | `qianfan` | `https://qianfan.baidubce.com/v2` | -| SiliconFlow | `siliconflow` | `https://api.siliconflow.cn/v1` | -| Step Fun | `stepfun` | `https://api.stepfun.com/v1` | -| vLLM | `vllm` | `http://localhost:8000/v1` | -| VolcEngine | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | -| VolcEngine Coding Plan | `volcengine_coding_plan` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| Xiaomi MIMO | `xiaomi_mimo` | `https://api.xiaomimimo.com/v1` | -| Zhipu AI (GLM) | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | - -
- -
-Proveedores de embeddings admitidos - -Los embeddings usan la misma lista que los LLM. Ejemplos habituales: - -| Proveedor | Binding | Ejemplo de modelo | -|:--|:--|:--| -| OpenAI | `openai` | `text-embedding-3-large` | -| DashScope | `dashscope` | `text-embedding-v3` | -| Ollama | `ollama` | `nomic-embed-text` | -| SiliconFlow | `siliconflow` | `BAAI/bge-m3` | -| vLLM | `vllm` | Cualquier modelo de embedding | -| OpenAI-compatible | `custom` | — | - -
- -
-Proveedores de búsqueda web admitidos - -| Proveedor | Variable de entorno | Notas | -|:--|:--|:--| -| Brave | `BRAVE_API_KEY` | Recomendado, hay nivel gratuito | -| Tavily | `TAVILY_API_KEY` | | -| Jina | `JINA_API_KEY` | | -| SearXNG | — | Autohospedado, sin clave API | -| DuckDuckGo | — | Sin clave API | -| Perplexity | `PERPLEXITY_API_KEY` | Requiere clave API | - -
- ```bash python -m deeptutor.api.run_server cd web && npm run dev -- -p 3782 @@ -407,7 +338,6 @@ deeptutor run deep_research "Attention mechanisms in transformers" ```bash deeptutor chat --capability deep_solve --kb my-kb -# En el REPL: /cap, /tool, /kb, /history, /notebook, /config para cambiar al vuelo ``` ```bash @@ -428,90 +358,28 @@ deeptutor session open ```
-Referencia completa de la CLI - -**Nivel superior** +Referencia completa de comandos CLI | Comando | Descripción | |:---|:---| -| `deeptutor run ` | Ejecuta una capacidad en un solo turno (`chat`, `deep_solve`, `deep_question`, `deep_research`, `math_animator`) | -| `deeptutor chat` | REPL interactivo con `--capability`, `--tool`, `--kb`, `--language`, etc. | -| `deeptutor serve` | Inicia el servidor API de DeepTutor | +| `deeptutor run ` | Una pasada | +| `deeptutor chat` | REPL | +| `deeptutor serve` | Servidor API | -**`deeptutor bot`** +**`deeptutor bot`** — list, create, start, stop -| Comando | Descripción | -|:---|:---| -| `deeptutor bot list` | Lista instancias de TutorBot | -| `deeptutor bot create ` | Crea e inicia un bot (`--name`, `--persona`, `--model`) | -| `deeptutor bot start ` | Inicia un bot | -| `deeptutor bot stop ` | Detiene un bot | +**`deeptutor kb`** — list, info, create, add, search, set-default, delete -**`deeptutor kb`** +**`deeptutor memory`** — show, clear -| Comando | Descripción | -|:---|:---| -| `deeptutor kb list` | Lista bases de conocimiento | -| `deeptutor kb info ` | Detalles de la base | -| `deeptutor kb create ` | Crea desde documentos (`--doc`, `--docs-dir`) | -| `deeptutor kb add ` | Añade documentos | -| `deeptutor kb search ` | Busca en la base | -| `deeptutor kb set-default ` | Define la KB por defecto | -| `deeptutor kb delete ` | Elimina (`--force`) | +**`deeptutor session`** — list, show, open, rename, delete -**`deeptutor memory`** +**`deeptutor notebook`** — list, create, show, add-md, replace-md, remove-record -| Comando | Descripción | -|:---|:---| -| `deeptutor memory show [file]` | Ver (`summary`, `profile`, `all`) | -| `deeptutor memory clear [file]` | Borrar (`--force`) | - -**`deeptutor session`** - -| Comando | Descripción | -|:---|:---| -| `deeptutor session list` | Lista sesiones (`--limit`) | -| `deeptutor session show ` | Mensajes de la sesión | -| `deeptutor session open ` | Reanudar en el REPL | -| `deeptutor session rename ` | Renombrar (`--title`) | -| `deeptutor session delete ` | Eliminar | - -**`deeptutor notebook`** - -| Comando | Descripción | -|:---|:---| -| `deeptutor notebook list` | Lista cuadernos | -| `deeptutor notebook create ` | Crear (`--description`) | -| `deeptutor notebook show ` | Ver registros | -| `deeptutor notebook add-md ` | Importar Markdown | -| `deeptutor notebook replace-md ` | Sustituir registro | -| `deeptutor notebook remove-record ` | Quitar registro | - -**`deeptutor config` / `plugin` / `provider`** - -| Comando | Descripción | -|:---|:---| -| `deeptutor config show` | Resumen de configuración | -| `deeptutor plugin list` | Herramientas y capacidades registradas | -| `deeptutor plugin info ` | Detalle de herramienta o capacidad | -| `deeptutor provider login ` | OAuth (`openai-codex`, `github-copilot`) | +**config / plugin / provider** — config show, plugin list/info, provider login
- -## 🗺️ Hoja de ruta - -| Estado | Hito | -|:---:|:---| -| 🔜 | **Autenticación e inicio de sesión** — Página de login opcional para despliegues públicos con multiusuario | -| 🔜 | **Temas y apariencia** — Más temas y personalización de la interfaz | -| 🔜 | **Integración LightRAG** — Integrar [LightRAG](https://github.com/HKUDS/LightRAG) como motor avanzado de bases de conocimiento | -| 🔜 | **Sitio de documentación** — Documentación completa con guías, referencia de API y tutoriales | - -> Si DeepTutor te resulta útil, [danos una estrella](https://github.com/HKUDS/DeepTutor/stargazers): ¡nos ayuda a seguir! - ---- - ## 🌐 Comunidad y ecosistema @@ -528,9 +396,6 @@ deeptutor session open ## 🤝 Contribuir
- -Esperamos que DeepTutor sea un regalo para la comunidad. 🎁 - Contributors diff --git a/assets/README/README_FR.md b/assets/README/README_FR.md index 81faa6a55..26c26d4e4 100644 --- a/assets/README/README_FR.md +++ b/assets/README/README_FR.md @@ -2,21 +2,19 @@ DeepTutor -# DeepTutor : tutorat personnalisé natif pour agents - -HKUDS%2FDeepTutor | Trendshift +# DeepTutor : vers un tutorat personnalisé fondé sur les agents [![Python 3.11+](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) [![Next.js 16](https://img.shields.io/badge/Next.js-16-000000?style=flat-square&logo=next.js&logoColor=white)](https://nextjs.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](../../LICENSE) [![GitHub release](https://img.shields.io/github/v/release/HKUDS/DeepTutor?style=flat-square&color=brightgreen)](https://github.com/HKUDS/DeepTutor/releases) -[![arXiv](https://img.shields.io/badge/arXiv-Coming_Soon-b31b1b?style=flat-square&logo=arxiv&logoColor=white)](#) +[![GitHub last commit](https://img.shields.io/github/last-commit/HKUDS/DeepTutor?style=flat-square)](https://github.com/HKUDS/DeepTutor/commits) [![Discord](https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/eRsjPgMU4t) [![Feishu](https://img.shields.io/badge/Feishu-Group-00D4AA?style=flat-square&logo=feishu&logoColor=white)](../../Communication.md) [![WeChat](https://img.shields.io/badge/WeChat-Group-07C160?style=flat-square&logo=wechat&logoColor=white)](https://github.com/HKUDS/DeepTutor/issues/78) -[Fonctionnalités](#key-features) · [Démarrage](#get-started) · [Explorer](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [Feuille de route](#roadmap) · [Communauté](#community) +[Fonctionnalités](#key-features) · [Démarrage](#get-started) · [Explorer](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [Communauté](#community) [🇬🇧 English](../../README.md) · [🇨🇳 中文](README_CN.md) · [🇯🇵 日本語](README_JA.md) · [🇪🇸 Español](README_ES.md) · [🇸🇦 العربية](README_AR.md) · [🇷🇺 Русский](README_RU.md) · [🇮🇳 हिन्दी](README_HI.md) · [🇵🇹 Português](README_PT.md) @@ -25,7 +23,7 @@ --- ### 📰 Actualités -> **[2026.4.4]** Ça faisait longtemps ! ✨ DeepTutor v1.0.0 est enfin là — évolution native agents : refonte complète de l’architecture, TutorBot et modes flexibles sous Apache-2.0. Un nouveau chapitre commence ! +> **[2026.3.24]** DeepTutor v1.0.0 est là ✨ — évolution native agents : refactor léger, TutorBot, modes flexibles sous Apache-2.0. > **[2026.2.6]** 🚀 10k étoiles en 39 jours — merci à la communauté ! @@ -35,9 +33,7 @@ ### 📦 Versions -> **[2026.4.7]** [v1.0.0-beta.2](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.2) — Invalidation du cache à l’exécution pour rechargement à chaud des réglages, sortie imbriquée MinerU, correctif WebSocket mimic, Python 3.11+ minimum et améliorations CI. - -> **[2026.4.4]** [v1.0.0-beta.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.1) — Réécriture native agents (DeepTutor 2.0) : modèle de plugins à deux niveaux (Tools + Capabilities), entrées CLI et SDK, TutorBot multicanal, Co-Writer, apprentissage guidé et mémoire persistante. +> **[2026.3.24]** [v1.0.0](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0) — Refonte native agents, outils flexibles, entrées CLI et SDK, TutorBot nanobot, Co-Writer, apprentissage guidé, mémoire persistante.
Versions précédentes @@ -121,71 +117,6 @@ EMBEDDING_HOST=https://api.openai.com/v1 EMBEDDING_DIMENSION=3072 ``` -
-Fournisseurs LLM pris en charge - -| Fournisseur | Binding | URL de base par défaut | -|:--|:--|:--| -| AiHubMix | `aihubmix` | `https://aihubmix.com/v1` | -| Anthropic | `anthropic` | `https://api.anthropic.com/v1` | -| Azure OpenAI | `azure_openai` | — | -| BytePlus | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | -| BytePlus Coding Plan | `byteplus_coding_plan` | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | -| Custom (OpenAI-compat) | `custom` | — | -| DashScope (Qwen) | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | -| DeepSeek | `deepseek` | `https://api.deepseek.com` | -| Gemini | `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai/` | -| GitHub Copilot | `github_copilot` | `https://api.githubcopilot.com` | -| Groq | `groq` | `https://api.groq.com/openai/v1` | -| MiniMax | `minimax` | `https://api.minimax.io/v1` | -| Mistral | `mistral` | `https://api.mistral.ai/v1` | -| Moonshot (Kimi) | `moonshot` | `https://api.moonshot.ai/v1` | -| Ollama | `ollama` | `http://localhost:11434/v1` | -| OpenAI | `openai` | `https://api.openai.com/v1` | -| OpenAI Codex | `openai_codex` | `https://chatgpt.com/backend-api` | -| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | -| OpenVINO Model Server | `ovms` | `http://localhost:8000/v3` | -| Qianfan (Ernie) | `qianfan` | `https://qianfan.baidubce.com/v2` | -| SiliconFlow | `siliconflow` | `https://api.siliconflow.cn/v1` | -| Step Fun | `stepfun` | `https://api.stepfun.com/v1` | -| vLLM | `vllm` | `http://localhost:8000/v1` | -| VolcEngine | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | -| VolcEngine Coding Plan | `volcengine_coding_plan` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| Xiaomi MIMO | `xiaomi_mimo` | `https://api.xiaomimimo.com/v1` | -| Zhipu AI (GLM) | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | - -
- -
-Fournisseurs d’embeddings pris en charge - -Les embeddings utilisent la même liste que les LLM. Exemples courants : - -| Fournisseur | Binding | Exemple de modèle | -|:--|:--|:--| -| OpenAI | `openai` | `text-embedding-3-large` | -| DashScope | `dashscope` | `text-embedding-v3` | -| Ollama | `ollama` | `nomic-embed-text` | -| SiliconFlow | `siliconflow` | `BAAI/bge-m3` | -| vLLM | `vllm` | Tout modèle d’embedding | -| Compatible OpenAI | `custom` | — | - -
- -
-Fournisseurs de recherche web pris en charge - -| Fournisseur | Clé d’environnement | Notes | -|:--|:--|:--| -| Brave | `BRAVE_API_KEY` | Recommandé, palier gratuit disponible | -| Tavily | `TAVILY_API_KEY` | | -| Jina | `JINA_API_KEY` | | -| SearXNG | — | Auto-hébergé, pas de clé API | -| DuckDuckGo | — | Pas de clé API | -| Perplexity | `PERPLEXITY_API_KEY` | Nécessite une clé API | - -
- ```bash python -m deeptutor.api.run_server cd web && npm run dev -- -p 3782 @@ -407,7 +338,6 @@ deeptutor run deep_research "Attention mechanisms in transformers" ```bash deeptutor chat --capability deep_solve --kb my-kb -# Dans le REPL : /cap, /tool, /kb, /history, /notebook, /config pour changer à la volée ``` ```bash @@ -428,90 +358,18 @@ deeptutor session open ```
-Référence complète de la CLI - -**Niveau racine** - -| Commande | Description | -|:---|:---| -| `deeptutor run ` | Exécute une capacité en un tour (`chat`, `deep_solve`, `deep_question`, `deep_research`, `math_animator`) | -| `deeptutor chat` | REPL interactif avec `--capability`, `--tool`, `--kb`, `--language`, etc. | -| `deeptutor serve` | Démarre le serveur API DeepTutor | - -**`deeptutor bot`** - -| Commande | Description | -|:---|:---| -| `deeptutor bot list` | Liste les instances TutorBot | -| `deeptutor bot create ` | Crée et démarre un bot (`--name`, `--persona`, `--model`) | -| `deeptutor bot start ` | Démarre un bot | -| `deeptutor bot stop ` | Arrête un bot | - -**`deeptutor kb`** - -| Commande | Description | -|:---|:---| -| `deeptutor kb list` | Liste les bases de connaissances | -| `deeptutor kb info ` | Détails d’une base | -| `deeptutor kb create ` | Crée à partir de documents (`--doc`, `--docs-dir`) | -| `deeptutor kb add ` | Ajoute des documents | -| `deeptutor kb search ` | Recherche dans la base | -| `deeptutor kb set-default ` | Définit la KB par défaut | -| `deeptutor kb delete ` | Supprime (`--force`) | - -**`deeptutor memory`** - -| Commande | Description | -|:---|:---| -| `deeptutor memory show [file]` | Afficher (`summary`, `profile`, `all`) | -| `deeptutor memory clear [file]` | Effacer (`--force`) | - -**`deeptutor session`** - -| Commande | Description | -|:---|:---| -| `deeptutor session list` | Liste des sessions (`--limit`) | -| `deeptutor session show ` | Messages de la session | -| `deeptutor session open ` | Reprendre dans le REPL | -| `deeptutor session rename ` | Renommer (`--title`) | -| `deeptutor session delete ` | Supprimer | - -**`deeptutor notebook`** +Référence CLI complète | Commande | Description | |:---|:---| -| `deeptutor notebook list` | Liste des carnets | -| `deeptutor notebook create ` | Créer (`--description`) | -| `deeptutor notebook show ` | Voir les enregistrements | -| `deeptutor notebook add-md ` | Importer du Markdown | -| `deeptutor notebook replace-md ` | Remplacer un enregistrement | -| `deeptutor notebook remove-record ` | Supprimer un enregistrement | - -**`deeptutor config` / `plugin` / `provider`** +| `deeptutor run ` | Un tour | +| `deeptutor chat` | REPL | +| `deeptutor serve` | API | -| Commande | Description | -|:---|:---| -| `deeptutor config show` | Résumé de la configuration | -| `deeptutor plugin list` | Outils et capacités enregistrés | -| `deeptutor plugin info ` | Détail d’un outil ou d’une capacité | -| `deeptutor provider login ` | Connexion OAuth (`openai-codex`, `github-copilot`) | +**bot**, **kb**, **memory**, **session**, **notebook**, **config / plugin / provider** — comme la version anglaise.
- -## 🗺️ Feuille de route - -| Statut | Jalons | -|:---:|:---| -| 🔜 | **Authentification et connexion** — Page de login optionnelle pour déploiements publics et multi-utilisateurs | -| 🔜 | **Thèmes et apparence** — Thèmes variés et personnalisation de l’interface | -| 🔜 | **Intégration LightRAG** — Intégrer [LightRAG](https://github.com/HKUDS/LightRAG) comme moteur avancé de bases de connaissances | -| 🔜 | **Site de documentation** — Documentation complète : guides, référence API et tutoriels | - -> Si DeepTutor vous est utile, [donnez-nous une étoile](https://github.com/HKUDS/DeepTutor/stargazers) — cela nous aide à continuer ! - ---- - ## 🌐 Communauté et écosystème @@ -528,9 +386,6 @@ deeptutor session open ## 🤝 Contribuer
- -Nous espérons que DeepTutor sera un cadeau pour la communauté. 🎁 - Contributors diff --git a/assets/README/README_HI.md b/assets/README/README_HI.md index 943ca61bc..d5903744f 100644 --- a/assets/README/README_HI.md +++ b/assets/README/README_HI.md @@ -2,21 +2,19 @@ DeepTutor -# DeepTutor: एजेंट-नेटिव व्यक्तिगत शिक्षण - -HKUDS%2FDeepTutor | Trendshift +# DeepTutor: एजेंटिक व्यक्तिगत शिक्षण की ओर [![Python 3.11+](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) [![Next.js 16](https://img.shields.io/badge/Next.js-16-000000?style=flat-square&logo=next.js&logoColor=white)](https://nextjs.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](../../LICENSE) [![GitHub release](https://img.shields.io/github/v/release/HKUDS/DeepTutor?style=flat-square&color=brightgreen)](https://github.com/HKUDS/DeepTutor/releases) -[![arXiv](https://img.shields.io/badge/arXiv-Coming_Soon-b31b1b?style=flat-square&logo=arxiv&logoColor=white)](#) +[![GitHub last commit](https://img.shields.io/github/last-commit/HKUDS/DeepTutor?style=flat-square)](https://github.com/HKUDS/DeepTutor/commits) [![Discord](https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/eRsjPgMU4t) [![Feishu](https://img.shields.io/badge/Feishu-Group-00D4AA?style=flat-square&logo=feishu&logoColor=white)](../../Communication.md) [![WeChat](https://img.shields.io/badge/WeChat-Group-07C160?style=flat-square&logo=wechat&logoColor=white)](https://github.com/HKUDS/DeepTutor/issues/78) -[मुख्य विशेषताएँ](#key-features) · [शुरू करें](#get-started) · [अन्वेषण](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [रोडमैप](#roadmap) · [समुदाय](#community) +[मुख्य विशेषताएँ](#key-features) · [शुरू करें](#get-started) · [अन्वेषण](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [समुदाय](#community) [🇬🇧 English](../../README.md) · [🇨🇳 中文](README_CN.md) · [🇯🇵 日本語](README_JA.md) · [🇪🇸 Español](README_ES.md) · [🇫🇷 Français](README_FR.md) · [🇸🇦 العربية](README_AR.md) · [🇷🇺 Русский](README_RU.md) · [🇵🇹 Português](README_PT.md) @@ -25,7 +23,7 @@ --- ### 📰 समाचार -> **[2026.4.4]** बहुत दिन बाद! ✨ DeepTutor v1.0.0 आ गया — Apache-2.0 के तहत एजेंट-नेटिव विकास: ज़मीन से आर्किटेक्चर रिराइट, TutorBot, लचीले मोड। नया अध्याय शुरू! +> **[2026.3.24]** DeepTutor v1.0.0 ✨ — Apache-2.0 के तहत एजेंट-नेटिव विकास: हल्का रिफैक्टर, TutorBot, लचीले मोड। > **[2026.2.6]** 🚀 39 दिनों में 10k सितारे — समुदाय का धन्यवाद! @@ -35,9 +33,7 @@ ### 📦 रिलीज़ -> **[2026.4.7]** [v1.0.0-beta.2](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.2) — गर्म सेटिंग रीलोड के लिए रनटाइम कैश इनवैलिडेशन, MinerU नेस्टेड आउटपुट, mimic WebSocket फिक्स, न्यूनतम Python 3.11+, CI सुधार। - -> **[2026.4.4]** [v1.0.0-beta.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.1) — एजेंट-नेटिव आर्किटेक्चर रिराइट (DeepTutor 2.0): दो-स्तरीय प्लगइन मॉडल (Tools + Capabilities), CLI व SDK प्रवेश, मल्टी-चैनल TutorBot, Co-Writer, Guided Learning, स्थायी मेमोरी। +> **[2026.3.24]** [v1.0.0](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0) — एजेंट-नेटिव रिफैक्टर, लचीला टूल इंटीग्रेशन, CLI व SDK प्रवेश, nanobot पर TutorBot, Co-Writer, Guided Learning, स्थायी मेमोरी।
पिछले रिलीज़ @@ -121,71 +117,6 @@ EMBEDDING_HOST=https://api.openai.com/v1 EMBEDDING_DIMENSION=3072 ``` -
-समर्थित LLM प्रदाता - -| प्रदाता | Binding | डिफ़ॉल्ट Base URL | -|:--|:--|:--| -| AiHubMix | `aihubmix` | `https://aihubmix.com/v1` | -| Anthropic | `anthropic` | `https://api.anthropic.com/v1` | -| Azure OpenAI | `azure_openai` | — | -| BytePlus | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | -| BytePlus Coding Plan | `byteplus_coding_plan` | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | -| Custom (OpenAI-compat) | `custom` | — | -| DashScope (Qwen) | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | -| DeepSeek | `deepseek` | `https://api.deepseek.com` | -| Gemini | `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai/` | -| GitHub Copilot | `github_copilot` | `https://api.githubcopilot.com` | -| Groq | `groq` | `https://api.groq.com/openai/v1` | -| MiniMax | `minimax` | `https://api.minimax.io/v1` | -| Mistral | `mistral` | `https://api.mistral.ai/v1` | -| Moonshot (Kimi) | `moonshot` | `https://api.moonshot.ai/v1` | -| Ollama | `ollama` | `http://localhost:11434/v1` | -| OpenAI | `openai` | `https://api.openai.com/v1` | -| OpenAI Codex | `openai_codex` | `https://chatgpt.com/backend-api` | -| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | -| OpenVINO Model Server | `ovms` | `http://localhost:8000/v3` | -| Qianfan (Ernie) | `qianfan` | `https://qianfan.baidubce.com/v2` | -| SiliconFlow | `siliconflow` | `https://api.siliconflow.cn/v1` | -| Step Fun | `stepfun` | `https://api.stepfun.com/v1` | -| vLLM | `vllm` | `http://localhost:8000/v1` | -| VolcEngine | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | -| VolcEngine Coding Plan | `volcengine_coding_plan` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| Xiaomi MIMO | `xiaomi_mimo` | `https://api.xiaomimimo.com/v1` | -| Zhipu AI (GLM) | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | - -
- -
-समर्थित एम्बेडिंग प्रदाता - -एम्बेडिंग के लिए LLM जैसी ही सूची। सामान्य उदाहरण: - -| प्रदाता | Binding | मॉडल उदाहरण | -|:--|:--|:--| -| OpenAI | `openai` | `text-embedding-3-large` | -| DashScope | `dashscope` | `text-embedding-v3` | -| Ollama | `ollama` | `nomic-embed-text` | -| SiliconFlow | `siliconflow` | `BAAI/bge-m3` | -| vLLM | `vllm` | कोई भी एम्बेडिंग मॉडल | -| OpenAI-संगत | `custom` | — | - -
- -
-समर्थित वेब खोज प्रदाता - -| प्रदाता | एन्व कुंजी | नोट | -|:--|:--|:--| -| Brave | `BRAVE_API_KEY` | अनुशंसित, मुफ़्त स्तर | -| Tavily | `TAVILY_API_KEY` | | -| Jina | `JINA_API_KEY` | | -| SearXNG | — | सेल्फ-होस्ट, API कुंजी नहीं | -| DuckDuckGo | — | API कुंजी नहीं | -| Perplexity | `PERPLEXITY_API_KEY` | API कुंजी आवश्यक | - -
- ```bash python -m deeptutor.api.run_server cd web && npm run dev -- -p 3782 @@ -407,7 +338,6 @@ deeptutor run deep_research "Attention mechanisms in transformers" ```bash deeptutor chat --capability deep_solve --kb my-kb -# REPL में: /cap, /tool, /kb, /history, /notebook, /config से तुरंत बदलाव ``` ```bash @@ -430,88 +360,16 @@ deeptutor session open
पूर्ण CLI संदर्भ -**शीर्ष स्तर** - -| कमांड | विवरण | -|:---|:---| -| `deeptutor run ` | एक बार में क्षमता चलाएँ (`chat`, `deep_solve`, `deep_question`, `deep_research`, `math_animator`) | -| `deeptutor chat` | इंटरैक्टिव REPL (`--capability`, `--tool`, `--kb`, `--language` आदि) | -| `deeptutor serve` | DeepTutor API सर्वर शुरू करें | - -**`deeptutor bot`** - -| कमांड | विवरण | -|:---|:---| -| `deeptutor bot list` | सभी TutorBot इंस्टेंस | -| `deeptutor bot create ` | नया बॉट बनाएँ और चलाएँ (`--name`, `--persona`, `--model`) | -| `deeptutor bot start ` | बॉट शुरू | -| `deeptutor bot stop ` | बॉट रोकें | - -**`deeptutor kb`** - -| कमांड | विवरण | -|:---|:---| -| `deeptutor kb list` | नॉलेज बेस सूची | -| `deeptutor kb info ` | विवरण | -| `deeptutor kb create ` | दस्तावेज़ों से बनाएँ (`--doc`, `--docs-dir`) | -| `deeptutor kb add ` | दस्तावेज़ जोड़ें | -| `deeptutor kb search ` | खोज | -| `deeptutor kb set-default ` | डिफ़ॉल्ट KB | -| `deeptutor kb delete ` | हटाएँ (`--force`) | - -**`deeptutor memory`** - -| कमांड | विवरण | -|:---|:---| -| `deeptutor memory show [file]` | देखें (`summary`, `profile`, `all`) | -| `deeptutor memory clear [file]` | साफ़ करें (`--force`) | - -**`deeptutor session`** - -| कमांड | विवरण | -|:---|:---| -| `deeptutor session list` | सत्र सूची (`--limit`) | -| `deeptutor session show ` | संदेश | -| `deeptutor session open ` | REPL में जारी रखें | -| `deeptutor session rename ` | नाम बदलें (`--title`) | -| `deeptutor session delete ` | हटाएँ | - -**`deeptutor notebook`** - | कमांड | विवरण | |:---|:---| -| `deeptutor notebook list` | नोटबुक सूची | -| `deeptutor notebook create ` | बनाएँ (`--description`) | -| `deeptutor notebook show ` | रिकॉर्ड | -| `deeptutor notebook add-md ` | Markdown आयात | -| `deeptutor notebook replace-md ` | रिकॉर्ड बदलें | -| `deeptutor notebook remove-record ` | रिकॉर्ड हटाएँ | - -**`deeptutor config` / `plugin` / `provider`** +| `deeptutor run ` | एक पास | +| `deeptutor chat` | REPL | +| `deeptutor serve` | API सर्वर | -| कमांड | विवरण | -|:---|:---| -| `deeptutor config show` | कॉन्फ़िग सारांश | -| `deeptutor plugin list` | पंजीकृत टूल और क्षमताएँ | -| `deeptutor plugin info ` | टूल या क्षमता विवरण | -| `deeptutor provider login ` | OAuth (`openai-codex`, `github-copilot`) | +**bot**, **kb**, **memory**, **session**, **notebook**, **config / plugin / provider** — अंग्रेज़ी README जैसा।
- -## 🗺️ रोडमैप - -| स्थिति | माइलस्टोन | -|:---:|:---| -| 🔜 | **प्रमाणीकरण व लॉगिन** — सार्वजनिक डिप्लॉय के लिए वैकल्पिक लॉगिन व बहु-उपयोगकर्ता | -| 🔜 | **थीम व रूप** — विविध थीम व अनुकूलित UI | -| 🔜 | **LightRAG एकीकरण** — [LightRAG](https://github.com/HKUDS/LightRAG) को उन्नत नॉलेज बेस इंजन के रूप में | -| 🔜 | **दस्तावेज़ साइट** — गाइड, API संदर्भ व ट्यूटोरियल सहित पूर्ण दस्तावेज़ीकरण | - -> यदि DeepTutor उपयोगी लगे तो [स्टार दें](https://github.com/HKUDS/DeepTutor/stargazers) — हमें प्रोत्साहन मिलता है! - ---- - ## 🌐 समुदाय व पारिस्थितिकी तंत्र @@ -528,9 +386,6 @@ deeptutor session open ## 🤝 योगदान
- -हम चाहते हैं कि DeepTutor समुदाय के लिए उपहार बने। 🎁 - Contributors diff --git a/assets/README/README_JA.md b/assets/README/README_JA.md index c2cb84b10..38841b045 100644 --- a/assets/README/README_JA.md +++ b/assets/README/README_JA.md @@ -2,21 +2,19 @@ DeepTutor -# DeepTutor: エージェントネイティブなパーソナライズドチュータリング - -HKUDS%2FDeepTutor | Trendshift +# DeepTutor: エージェント型パーソナライズドチュータリングへ [![Python 3.11+](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) [![Next.js 16](https://img.shields.io/badge/Next.js-16-000000?style=flat-square&logo=next.js&logoColor=white)](https://nextjs.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](../../LICENSE) [![GitHub release](https://img.shields.io/github/v/release/HKUDS/DeepTutor?style=flat-square&color=brightgreen)](https://github.com/HKUDS/DeepTutor/releases) -[![arXiv](https://img.shields.io/badge/arXiv-Coming_Soon-b31b1b?style=flat-square&logo=arxiv&logoColor=white)](#) +[![GitHub last commit](https://img.shields.io/github/last-commit/HKUDS/DeepTutor?style=flat-square)](https://github.com/HKUDS/DeepTutor/commits) [![Discord](https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/eRsjPgMU4t) [![Feishu](https://img.shields.io/badge/Feishu-Group-00D4AA?style=flat-square&logo=feishu&logoColor=white)](../../Communication.md) [![WeChat](https://img.shields.io/badge/WeChat-Group-07C160?style=flat-square&logo=wechat&logoColor=white)](https://github.com/HKUDS/DeepTutor/issues/78) -[主な機能](#key-features) · [はじめる](#get-started) · [DeepTutor を探る](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [ロードマップ](#roadmap) · [コミュニティ](#community) +[主な機能](#key-features) · [はじめる](#get-started) · [DeepTutor を探る](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [コミュニティ](#community) [🇬🇧 English](../../README.md) · [🇨🇳 中文](README_CN.md) · [🇪🇸 Español](README_ES.md) · [🇫🇷 Français](README_FR.md) · [🇸🇦 العربية](README_AR.md) · [🇷🇺 Русский](README_RU.md) · [🇮🇳 हिन्दी](README_HI.md) · [🇵🇹 Português](README_PT.md) @@ -25,7 +23,7 @@ --- ### 📰 ニュース -> **[2026.4.4]** お久しぶりです!✨ DeepTutor v1.0.0 がついに登場 — Apache-2.0 のもと、ゼロからの架構書き直し、TutorBot、柔軟なモード切替を備えたエージェントネイティブな進化です。新章の始まりです! +> **[2026.3.24]** お待たせしました!✨ DeepTutor v1.0.0 を Apache-2.0 で公開 — 軽量リファクタ、TutorBot、柔軟なモード切替を備えたエージェントネイティブな進化です。 > **[2026.2.6]** 🚀 わずか 39 日で 10k スターに到達。コミュニティに感謝します! @@ -35,9 +33,7 @@ ### 📦 リリース -> **[2026.4.7]** [v1.0.0-beta.2](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.2) — 実行時キャッシュ無効化によるホットリロード設定、MinerU ネスト出力対応、mimic WebSocket 修正、最低 Python 3.11+、CI 改善。 - -> **[2026.4.4]** [v1.0.0-beta.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.1) — エージェントネイティブ架構の書き直し(DeepTutor 2.0):二層プラグインモデル(Tools + Capabilities)、CLI と SDK の入口、マルチチャネル TutorBot、Co-Writer、ガイド付き学習、永続メモリ。 +> **[2026.3.24]** [v1.0.0](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0) — エージェントネイティブな再設計、柔軟なツール統合、CLI と SDK の入口、nanobot 駆動の TutorBot、Co-Writer、ガイド付き学習、永続メモリ。より軽く、速く、使いやすく。
過去のリリース @@ -127,71 +123,6 @@ EMBEDDING_HOST=https://api.openai.com/v1 EMBEDDING_DIMENSION=3072 ``` -
-対応 LLM プロバイダ - -| プロバイダ | Binding | 既定 Base URL | -|:--|:--|:--| -| AiHubMix | `aihubmix` | `https://aihubmix.com/v1` | -| Anthropic | `anthropic` | `https://api.anthropic.com/v1` | -| Azure OpenAI | `azure_openai` | — | -| BytePlus | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | -| BytePlus Coding Plan | `byteplus_coding_plan` | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | -| Custom (OpenAI-compat) | `custom` | — | -| DashScope (Qwen) | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | -| DeepSeek | `deepseek` | `https://api.deepseek.com` | -| Gemini | `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai/` | -| GitHub Copilot | `github_copilot` | `https://api.githubcopilot.com` | -| Groq | `groq` | `https://api.groq.com/openai/v1` | -| MiniMax | `minimax` | `https://api.minimax.io/v1` | -| Mistral | `mistral` | `https://api.mistral.ai/v1` | -| Moonshot (Kimi) | `moonshot` | `https://api.moonshot.ai/v1` | -| Ollama | `ollama` | `http://localhost:11434/v1` | -| OpenAI | `openai` | `https://api.openai.com/v1` | -| OpenAI Codex | `openai_codex` | `https://chatgpt.com/backend-api` | -| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | -| OpenVINO Model Server | `ovms` | `http://localhost:8000/v3` | -| Qianfan (Ernie) | `qianfan` | `https://qianfan.baidubce.com/v2` | -| SiliconFlow | `siliconflow` | `https://api.siliconflow.cn/v1` | -| Step Fun | `stepfun` | `https://api.stepfun.com/v1` | -| vLLM | `vllm` | `http://localhost:8000/v1` | -| VolcEngine | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | -| VolcEngine Coding Plan | `volcengine_coding_plan` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| Xiaomi MIMO | `xiaomi_mimo` | `https://api.xiaomimimo.com/v1` | -| Zhipu AI (GLM) | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | - -
- -
-対応 Embedding プロバイダ - -埋め込みは LLM と同じプロバイダ一覧を使用します。よく使う例: - -| プロバイダ | Binding | モデル例 | -|:--|:--|:--| -| OpenAI | `openai` | `text-embedding-3-large` | -| DashScope | `dashscope` | `text-embedding-v3` | -| Ollama | `ollama` | `nomic-embed-text` | -| SiliconFlow | `siliconflow` | `BAAI/bge-m3` | -| vLLM | `vllm` | 任意の埋め込みモデル | -| OpenAI 互換 | `custom` | — | - -
- -
-対応 Web 検索プロバイダ - -| プロバイダ | 環境変数 | メモ | -|:--|:--|:--| -| Brave | `BRAVE_API_KEY` | 推奨、無料枠あり | -| Tavily | `TAVILY_API_KEY` | | -| Jina | `JINA_API_KEY` | | -| SearXNG | — | 自ホスト、API キー不要 | -| DuckDuckGo | — | API キー不要 | -| Perplexity | `PERPLEXITY_API_KEY` | API キー必須 | - -
- **3. 起動** ```bash @@ -419,7 +350,6 @@ deeptutor run deep_research "Attention mechanisms in transformers" ```bash deeptutor chat --capability deep_solve --kb my-kb -# REPL 内: /cap、/tool、/kb、/history、/notebook、/config などで切替 ``` ```bash @@ -440,90 +370,28 @@ deeptutor session open ```
-CLI コマンドリファレンス(全コマンド) - -**トップレベル** - -| コマンド | 説明 | -|:---|:---| -| `deeptutor run ` | 単発で能力を実行(`chat`、`deep_solve`、`deep_question`、`deep_research`、`math_animator`) | -| `deeptutor chat` | 対話 REPL(`--capability`、`--tool`、`--kb`、`--language` など) | -| `deeptutor serve` | DeepTutor API サーバを起動 | - -**`deeptutor bot`** +CLI コマンド一覧 | コマンド | 説明 | |:---|:---| -| `deeptutor bot list` | TutorBot 一覧 | -| `deeptutor bot create ` | 新規作成・起動(`--name`、`--persona`、`--model`) | -| `deeptutor bot start ` | 起動 | -| `deeptutor bot stop ` | 停止 | +| `deeptutor run ` | 1 ターン実行 | +| `deeptutor chat` | REPL | +| `deeptutor serve` | API サーバ | -**`deeptutor kb`** +**`deeptutor bot`** — `list` / `create` / `start` / `stop` -| コマンド | 説明 | -|:---|:---| -| `deeptutor kb list` | ナレッジベース一覧 | -| `deeptutor kb info ` | 詳細 | -| `deeptutor kb create ` | ドキュメントから作成(`--doc`、`--docs-dir`) | -| `deeptutor kb add ` | ドキュメントを追加 | -| `deeptutor kb search ` | 検索 | -| `deeptutor kb set-default ` | デフォルト KB に設定 | -| `deeptutor kb delete ` | 削除(`--force`) | +**`deeptutor kb`** — `list` / `info` / `create` / `add` / `search` / `set-default` / `delete` -**`deeptutor memory`** +**`deeptutor memory`** — `show` / `clear` -| コマンド | 説明 | -|:---|:---| -| `deeptutor memory show [file]` | 表示(`summary`、`profile`、`all`) | -| `deeptutor memory clear [file]` | クリア(`--force`) | +**`deeptutor session`** — `list` / `show` / `open` / `rename` / `delete` -**`deeptutor session`** +**`deeptutor notebook`** — `list` / `create` / `show` / `add-md` / `replace-md` / `remove-record` -| コマンド | 説明 | -|:---|:---| -| `deeptutor session list` | 一覧(`--limit`) | -| `deeptutor session show ` | メッセージ表示 | -| `deeptutor session open ` | REPL で再開 | -| `deeptutor session rename ` | 名前変更(`--title`) | -| `deeptutor session delete ` | 削除 | - -**`deeptutor notebook`** - -| コマンド | 説明 | -|:---|:---| -| `deeptutor notebook list` | 一覧 | -| `deeptutor notebook create ` | 作成(`--description`) | -| `deeptutor notebook show ` | レコード表示 | -| `deeptutor notebook add-md ` | Markdown をインポート | -| `deeptutor notebook replace-md ` | レコードを置換 | -| `deeptutor notebook remove-record ` | レコード削除 | - -**`deeptutor config` / `plugin` / `provider`** - -| コマンド | 説明 | -|:---|:---| -| `deeptutor config show` | 設定サマリを表示 | -| `deeptutor plugin list` | 登録済みツールと能力 | -| `deeptutor plugin info ` | ツールまたは能力の詳細 | -| `deeptutor provider login ` | OAuth ログイン(`openai-codex`、`github-copilot`) | +**`config` / `plugin` / `provider`** — `config show`、`plugin list/info`、`provider login`
- -## 🗺️ ロードマップ - -| 状態 | マイルストーン | -|:---:|:---| -| 🔜 | **認証とログイン** — 公開向けデプロイ用の任意ログインとマルチユーザー | -| 🔜 | **テーマと外観** — 多彩なテーマと UI のカスタマイズ | -| 🔜 | **LightRAG 統合** — [LightRAG](https://github.com/HKUDS/LightRAG) を高度な KB エンジンとして統合 | -| 🔜 | **ドキュメントサイト** — ガイド、API リファレンス、チュートリアルを含む公式ドキュメント | - -> DeepTutor が役に立ったら [Star を付ける](https://github.com/HKUDS/DeepTutor/stargazers) と開発の励みになります! - ---- - ## 🌐 コミュニティとエコシステム @@ -541,8 +409,6 @@ deeptutor session open
-DeepTutor がコミュニティへの贈り物になれば幸いです。🎁 - Contributors diff --git a/assets/README/README_PT.md b/assets/README/README_PT.md index 594144181..8bea31044 100644 --- a/assets/README/README_PT.md +++ b/assets/README/README_PT.md @@ -2,30 +2,28 @@ DeepTutor -# DeepTutor: tutoria personalizada nativa para agentes - -HKUDS%2FDeepTutor | Trendshift +# DeepTutor: Rumo a um tutorado personalizado baseado em agentes [![Python 3.11+](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) [![Next.js 16](https://img.shields.io/badge/Next.js-16-000000?style=flat-square&logo=next.js&logoColor=white)](https://nextjs.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](../../LICENSE) [![GitHub release](https://img.shields.io/github/v/release/HKUDS/DeepTutor?style=flat-square&color=brightgreen)](https://github.com/HKUDS/DeepTutor/releases) -[![arXiv](https://img.shields.io/badge/arXiv-Coming_Soon-b31b1b?style=flat-square&logo=arxiv&logoColor=white)](#) +[![GitHub last commit](https://img.shields.io/github/last-commit/HKUDS/DeepTutor?style=flat-square)](https://github.com/HKUDS/DeepTutor/commits) [![Discord](https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/eRsjPgMU4t) [![Feishu](https://img.shields.io/badge/Feishu-Group-00D4AA?style=flat-square&logo=feishu&logoColor=white)](../../Communication.md) [![WeChat](https://img.shields.io/badge/WeChat-Group-07C160?style=flat-square&logo=wechat&logoColor=white)](https://github.com/HKUDS/DeepTutor/issues/78) -[Recursos](#key-features) · [Começar](#get-started) · [Explorar](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [Roteiro](#roadmap) · [Comunidade](#community) +[Recursos](#key-features) · [Começar](#get-started) · [Explorar](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [Comunidade](#community) -[🇬🇧 English](../../README.md) · [🇨🇳 中文](README_CN.md) · [🇯🇵 日本語](README_JA.md) · [🇪🇸 Español](README_ES.md) · [🇫🇷 Français](README_FR.md) · [🇸🇦 العربية](README_AR.md) · [🇷🇺 Русский](README_RU.md) · [🇮🇳 हिन्दी](README_HI.md) · [🇵🇹 Português](README_PT.md) +[🇬🇧 English](../../README.md) · [🇨🇳 中文](README_CN.md) · [🇯🇵 日本語](README_JA.md) · [🇪🇸 Español](README_ES.md) · [🇫🇷 Français](README_FR.md) · [🇸🇦 العربية](README_AR.md) · [🇷🇺 Русский](README_RU.md) · [🇮🇳 हिन्दी](README_HI.md)
--- ### 📰 Notícias -> **[2026.4.4]** Há quanto tempo! ✨ DeepTutor v1.0.0 chegou — evolução nativa de agentes com reescrita da arquitetura do zero, TutorBot e modos flexíveis sob Apache-2.0. Um novo capítulo começa! +> **[2026.3.24]** DeepTutor v1.0.0 chegou ✨ — evolução nativa de agentes: refactor leve, TutorBot e modos flexíveis sob Apache-2.0. > **[2026.2.6]** 🚀 10k estrelas em 39 dias — obrigado à comunidade! @@ -35,9 +33,7 @@ ### 📦 Lançamentos -> **[2026.4.7]** [v1.0.0-beta.2](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.2) — Invalidação de cache em tempo de execução para recarregar ajustes a quente, saída aninhada MinerU, correção do WebSocket mimic, mínimo Python 3.11+ e melhorias de CI. - -> **[2026.4.4]** [v1.0.0-beta.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.1) — Reescrita nativa de agentes (DeepTutor 2.0): modelo de plugins em duas camadas (Tools + Capabilities), entradas CLI e SDK, TutorBot multicanal, Co-Writer, aprendizado guiado e memória persistente. +> **[2026.3.24]** [v1.0.0](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0) — Refatoração nativa de agentes, integração flexível de ferramentas, entradas CLI e SDK, TutorBot com nanobot, Co-Writer, aprendizado guiado e memória persistente.
Lançamentos anteriores @@ -121,71 +117,6 @@ EMBEDDING_HOST=https://api.openai.com/v1 EMBEDDING_DIMENSION=3072 ``` -
-Provedores LLM suportados - -| Provedor | Binding | URL base padrão | -|:--|:--|:--| -| AiHubMix | `aihubmix` | `https://aihubmix.com/v1` | -| Anthropic | `anthropic` | `https://api.anthropic.com/v1` | -| Azure OpenAI | `azure_openai` | — | -| BytePlus | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | -| BytePlus Coding Plan | `byteplus_coding_plan` | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | -| Custom (OpenAI-compat) | `custom` | — | -| DashScope (Qwen) | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | -| DeepSeek | `deepseek` | `https://api.deepseek.com` | -| Gemini | `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai/` | -| GitHub Copilot | `github_copilot` | `https://api.githubcopilot.com` | -| Groq | `groq` | `https://api.groq.com/openai/v1` | -| MiniMax | `minimax` | `https://api.minimax.io/v1` | -| Mistral | `mistral` | `https://api.mistral.ai/v1` | -| Moonshot (Kimi) | `moonshot` | `https://api.moonshot.ai/v1` | -| Ollama | `ollama` | `http://localhost:11434/v1` | -| OpenAI | `openai` | `https://api.openai.com/v1` | -| OpenAI Codex | `openai_codex` | `https://chatgpt.com/backend-api` | -| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | -| OpenVINO Model Server | `ovms` | `http://localhost:8000/v3` | -| Qianfan (Ernie) | `qianfan` | `https://qianfan.baidubce.com/v2` | -| SiliconFlow | `siliconflow` | `https://api.siliconflow.cn/v1` | -| Step Fun | `stepfun` | `https://api.stepfun.com/v1` | -| vLLM | `vllm` | `http://localhost:8000/v1` | -| VolcEngine | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | -| VolcEngine Coding Plan | `volcengine_coding_plan` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| Xiaomi MIMO | `xiaomi_mimo` | `https://api.xiaomimimo.com/v1` | -| Zhipu AI (GLM) | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | - -
- -
-Provedores de embedding suportados - -Os embeddings usam a mesma lista dos LLM. Exemplos comuns: - -| Provedor | Binding | Exemplo de modelo | -|:--|:--|:--| -| OpenAI | `openai` | `text-embedding-3-large` | -| DashScope | `dashscope` | `text-embedding-v3` | -| Ollama | `ollama` | `nomic-embed-text` | -| SiliconFlow | `siliconflow` | `BAAI/bge-m3` | -| vLLM | `vllm` | Qualquer modelo de embedding | -| Compatível OpenAI | `custom` | — | - -
- -
-Provedores de busca web suportados - -| Provedor | Variável de ambiente | Notas | -|:--|:--|:--| -| Brave | `BRAVE_API_KEY` | Recomendado, há nível gratuito | -| Tavily | `TAVILY_API_KEY` | | -| Jina | `JINA_API_KEY` | | -| SearXNG | — | Auto-hospedado, sem chave API | -| DuckDuckGo | — | Sem chave API | -| Perplexity | `PERPLEXITY_API_KEY` | Requer chave API | - -
- ```bash python -m deeptutor.api.run_server cd web && npm run dev -- -p 3782 @@ -407,7 +338,6 @@ deeptutor run deep_research "Attention mechanisms in transformers" ```bash deeptutor chat --capability deep_solve --kb my-kb -# No REPL: /cap, /tool, /kb, /history, /notebook, /config para alternar em tempo real ``` ```bash @@ -430,88 +360,16 @@ deeptutor session open
Referência completa da CLI -**Nível superior** - -| Comando | Descrição | -|:---|:---| -| `deeptutor run ` | Executa uma capacidade em um turno (`chat`, `deep_solve`, `deep_question`, `deep_research`, `math_animator`) | -| `deeptutor chat` | REPL interativo com `--capability`, `--tool`, `--kb`, `--language`, etc. | -| `deeptutor serve` | Inicia o servidor API do DeepTutor | - -**`deeptutor bot`** - -| Comando | Descrição | -|:---|:---| -| `deeptutor bot list` | Lista instâncias do TutorBot | -| `deeptutor bot create ` | Cria e inicia um bot (`--name`, `--persona`, `--model`) | -| `deeptutor bot start ` | Inicia um bot | -| `deeptutor bot stop ` | Para um bot | - -**`deeptutor kb`** - -| Comando | Descrição | -|:---|:---| -| `deeptutor kb list` | Lista bases de conhecimento | -| `deeptutor kb info ` | Detalhes da base | -| `deeptutor kb create ` | Cria a partir de documentos (`--doc`, `--docs-dir`) | -| `deeptutor kb add ` | Adiciona documentos | -| `deeptutor kb search ` | Busca na base | -| `deeptutor kb set-default ` | Define KB padrão | -| `deeptutor kb delete ` | Remove (`--force`) | - -**`deeptutor memory`** - -| Comando | Descrição | -|:---|:---| -| `deeptutor memory show [file]` | Ver (`summary`, `profile`, `all`) | -| `deeptutor memory clear [file]` | Limpar (`--force`) | - -**`deeptutor session`** - -| Comando | Descrição | -|:---|:---| -| `deeptutor session list` | Lista sessões (`--limit`) | -| `deeptutor session show ` | Mensagens da sessão | -| `deeptutor session open ` | Retomar no REPL | -| `deeptutor session rename ` | Renomear (`--title`) | -| `deeptutor session delete ` | Excluir | - -**`deeptutor notebook`** - | Comando | Descrição | |:---|:---| -| `deeptutor notebook list` | Lista cadernos | -| `deeptutor notebook create ` | Criar (`--description`) | -| `deeptutor notebook show ` | Registros | -| `deeptutor notebook add-md ` | Importar Markdown | -| `deeptutor notebook replace-md ` | Substituir registro | -| `deeptutor notebook remove-record ` | Remover registro | - -**`deeptutor config` / `plugin` / `provider`** +| `deeptutor run ` | Uma passagem | +| `deeptutor chat` | REPL | +| `deeptutor serve` | Servidor API | -| Comando | Descrição | -|:---|:---| -| `deeptutor config show` | Resumo da configuração | -| `deeptutor plugin list` | Ferramentas e capacidades registradas | -| `deeptutor plugin info ` | Detalhe de ferramenta ou capacidade | -| `deeptutor provider login ` | OAuth (`openai-codex`, `github-copilot`) | +**bot**, **kb**, **memory**, **session**, **notebook**, **config / plugin / provider** — como no README em inglês.
- -## 🗺️ Roteiro - -| Status | Marco | -|:---:|:---| -| 🔜 | **Autenticação e login** — Página de login opcional para implantações públicas com multiusuário | -| 🔜 | **Temas e aparência** — Mais temas e personalização da interface | -| 🔜 | **Integração LightRAG** — Integrar [LightRAG](https://github.com/HKUDS/LightRAG) como motor avançado de bases de conhecimento | -| 🔜 | **Site de documentação** — Documentação completa com guias, referência de API e tutoriais | - -> Se o DeepTutor for útil para você, [dê uma estrela](https://github.com/HKUDS/DeepTutor/stargazers) — isso nos ajuda a continuar! - ---- - ## 🌐 Comunidade e ecossistema @@ -528,9 +386,6 @@ deeptutor session open ## 🤝 Contribuir
- -Esperamos que o DeepTutor seja um presente para a comunidade. 🎁 - Contributors diff --git a/assets/README/README_RU.md b/assets/README/README_RU.md index 1d4c686a5..4bd5f6f80 100644 --- a/assets/README/README_RU.md +++ b/assets/README/README_RU.md @@ -2,21 +2,19 @@ DeepTutor -# DeepTutor: агентно-нативное персонализированное обучение - -HKUDS%2FDeepTutor | Trendshift +# DeepTutor: к персонализированному обучению на основе агентов [![Python 3.11+](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) [![Next.js 16](https://img.shields.io/badge/Next.js-16-000000?style=flat-square&logo=next.js&logoColor=white)](https://nextjs.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](../../LICENSE) [![GitHub release](https://img.shields.io/github/v/release/HKUDS/DeepTutor?style=flat-square&color=brightgreen)](https://github.com/HKUDS/DeepTutor/releases) -[![arXiv](https://img.shields.io/badge/arXiv-Coming_Soon-b31b1b?style=flat-square&logo=arxiv&logoColor=white)](#) +[![GitHub last commit](https://img.shields.io/github/last-commit/HKUDS/DeepTutor?style=flat-square)](https://github.com/HKUDS/DeepTutor/commits) [![Discord](https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/eRsjPgMU4t) [![Feishu](https://img.shields.io/badge/Feishu-Group-00D4AA?style=flat-square&logo=feishu&logoColor=white)](../../Communication.md) [![WeChat](https://img.shields.io/badge/WeChat-Group-07C160?style=flat-square&logo=wechat&logoColor=white)](https://github.com/HKUDS/DeepTutor/issues/78) -[Возможности](#key-features) · [Быстрый старт](#get-started) · [Обзор](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [Дорожная карта](#roadmap) · [Сообщество](#community) +[Возможности](#key-features) · [Быстрый старт](#get-started) · [Обзор](#explore-deeptutor) · [TutorBot](#tutorbot) · [CLI](#deeptutor-cli-guide) · [Сообщество](#community) [🇬🇧 English](../../README.md) · [🇨🇳 中文](README_CN.md) · [🇯🇵 日本語](README_JA.md) · [🇪🇸 Español](README_ES.md) · [🇫🇷 Français](README_FR.md) · [🇸🇦 العربية](README_AR.md) · [🇮🇳 हिन्दी](README_HI.md) · [🇵🇹 Português](README_PT.md) @@ -25,7 +23,7 @@ --- ### 📰 Новости -> **[2026.4.4]** Давно не виделись! ✨ Вышел DeepTutor v1.0.0 — агентно-нативная эволюция: архитектура переписана с нуля, TutorBot и гибкие режимы под Apache-2.0. Начинается новая глава! +> **[2026.3.24]** DeepTutor v1.0.0 ✨ — агентно-нативная эволюция: лёгкий рефакторинг, TutorBot, гибкие режимы под Apache-2.0. > **[2026.2.6]** 🚀 10k звёзд за 39 дней — спасибо сообществу! @@ -35,9 +33,7 @@ ### 📦 Релизы -> **[2026.4.7]** [v1.0.0-beta.2](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.2) — инвалидация кэша во время выполнения для горячей перезагрузки настроек, вложенный вывод MinerU, исправление mimic WebSocket, минимум Python 3.11+ и улучшения CI. - -> **[2026.4.4]** [v1.0.0-beta.1](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0-beta.1) — агентно-нативная переработка архитектуры (DeepTutor 2.0): двухуровневая модель плагинов (Tools + Capabilities), входы CLI и SDK, многоканальный TutorBot, Co-Writer, Guided Learning и постоянная память. +> **[2026.3.24]** [v1.0.0](https://github.com/HKUDS/DeepTutor/releases/tag/v1.0.0) — агентно-нативный рефакторинг, гибкие инструменты, CLI и SDK, TutorBot на nanobot, Co-Writer, Guided Learning, постоянная память.
Прошлые релизы @@ -121,71 +117,6 @@ EMBEDDING_HOST=https://api.openai.com/v1 EMBEDDING_DIMENSION=3072 ``` -
-Поддерживаемые провайдеры LLM - -| Провайдер | Binding | Базовый URL по умолчанию | -|:--|:--|:--| -| AiHubMix | `aihubmix` | `https://aihubmix.com/v1` | -| Anthropic | `anthropic` | `https://api.anthropic.com/v1` | -| Azure OpenAI | `azure_openai` | — | -| BytePlus | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | -| BytePlus Coding Plan | `byteplus_coding_plan` | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | -| Custom (OpenAI-compat) | `custom` | — | -| DashScope (Qwen) | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | -| DeepSeek | `deepseek` | `https://api.deepseek.com` | -| Gemini | `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai/` | -| GitHub Copilot | `github_copilot` | `https://api.githubcopilot.com` | -| Groq | `groq` | `https://api.groq.com/openai/v1` | -| MiniMax | `minimax` | `https://api.minimax.io/v1` | -| Mistral | `mistral` | `https://api.mistral.ai/v1` | -| Moonshot (Kimi) | `moonshot` | `https://api.moonshot.ai/v1` | -| Ollama | `ollama` | `http://localhost:11434/v1` | -| OpenAI | `openai` | `https://api.openai.com/v1` | -| OpenAI Codex | `openai_codex` | `https://chatgpt.com/backend-api` | -| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | -| OpenVINO Model Server | `ovms` | `http://localhost:8000/v3` | -| Qianfan (Ernie) | `qianfan` | `https://qianfan.baidubce.com/v2` | -| SiliconFlow | `siliconflow` | `https://api.siliconflow.cn/v1` | -| Step Fun | `stepfun` | `https://api.stepfun.com/v1` | -| vLLM | `vllm` | `http://localhost:8000/v1` | -| VolcEngine | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | -| VolcEngine Coding Plan | `volcengine_coding_plan` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| Xiaomi MIMO | `xiaomi_mimo` | `https://api.xiaomimimo.com/v1` | -| Zhipu AI (GLM) | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | - -
- -
-Поддерживаемые провайдеры эмбеддингов - -Эмбеддинги используют тот же список, что и LLM. Частые примеры: - -| Провайдер | Binding | Пример модели | -|:--|:--|:--| -| OpenAI | `openai` | `text-embedding-3-large` | -| DashScope | `dashscope` | `text-embedding-v3` | -| Ollama | `ollama` | `nomic-embed-text` | -| SiliconFlow | `siliconflow` | `BAAI/bge-m3` | -| vLLM | `vllm` | Любая embedding-модель | -| OpenAI-совместимый | `custom` | — | - -
- -
-Поддерживаемые веб-поисковые провайдеры - -| Провайдер | Переменная окружения | Примечания | -|:--|:--|:--| -| Brave | `BRAVE_API_KEY` | Рекомендуется, есть бесплатный уровень | -| Tavily | `TAVILY_API_KEY` | | -| Jina | `JINA_API_KEY` | | -| SearXNG | — | Самохостинг, без API-ключа | -| DuckDuckGo | — | Без API-ключа | -| Perplexity | `PERPLEXITY_API_KEY` | Нужен API-ключ | - -
- ```bash python -m deeptutor.api.run_server cd web && npm run dev -- -p 3782 @@ -407,7 +338,6 @@ deeptutor run deep_research "Attention mechanisms in transformers" ```bash deeptutor chat --capability deep_solve --kb my-kb -# В REPL: /cap, /tool, /kb, /history, /notebook, /config для переключения на лету ``` ```bash @@ -428,90 +358,18 @@ deeptutor session open ```
-Полная справка по CLI - -**Верхний уровень** - -| Команда | Описание | -|:---|:---| -| `deeptutor run ` | Запуск возможности за один ход (`chat`, `deep_solve`, `deep_question`, `deep_research`, `math_animator`) | -| `deeptutor chat` | Интерактивный REPL с `--capability`, `--tool`, `--kb`, `--language` и др. | -| `deeptutor serve` | Запуск сервера API DeepTutor | - -**`deeptutor bot`** - -| Команда | Описание | -|:---|:---| -| `deeptutor bot list` | Список экземпляров TutorBot | -| `deeptutor bot create ` | Создать и запустить бота (`--name`, `--persona`, `--model`) | -| `deeptutor bot start ` | Запустить бота | -| `deeptutor bot stop ` | Остановить бота | - -**`deeptutor kb`** - -| Команда | Описание | -|:---|:---| -| `deeptutor kb list` | Список баз знаний | -| `deeptutor kb info ` | Детали базы | -| `deeptutor kb create ` | Создать из документов (`--doc`, `--docs-dir`) | -| `deeptutor kb add ` | Добавить документы | -| `deeptutor kb search ` | Поиск по базе | -| `deeptutor kb set-default ` | База по умолчанию | -| `deeptutor kb delete ` | Удалить (`--force`) | - -**`deeptutor memory`** - -| Команда | Описание | -|:---|:---| -| `deeptutor memory show [file]` | Просмотр (`summary`, `profile`, `all`) | -| `deeptutor memory clear [file]` | Очистить (`--force`) | - -**`deeptutor session`** - -| Команда | Описание | -|:---|:---| -| `deeptutor session list` | Список сессий (`--limit`) | -| `deeptutor session show ` | Сообщения сессии | -| `deeptutor session open ` | Продолжить в REPL | -| `deeptutor session rename ` | Переименовать (`--title`) | -| `deeptutor session delete ` | Удалить | - -**`deeptutor notebook`** +Полная справка CLI | Команда | Описание | |:---|:---| -| `deeptutor notebook list` | Список блокнотов | -| `deeptutor notebook create ` | Создать (`--description`) | -| `deeptutor notebook show ` | Записи | -| `deeptutor notebook add-md ` | Импорт Markdown | -| `deeptutor notebook replace-md ` | Заменить запись | -| `deeptutor notebook remove-record ` | Удалить запись | - -**`deeptutor config` / `plugin` / `provider`** +| `deeptutor run ` | Один проход | +| `deeptutor chat` | REPL | +| `deeptutor serve` | API-сервер | -| Команда | Описание | -|:---|:---| -| `deeptutor config show` | Сводка конфигурации | -| `deeptutor plugin list` | Зарегистрированные инструменты и возможности | -| `deeptutor plugin info ` | Детали инструмента или возможности | -| `deeptutor provider login ` | OAuth (`openai-codex`, `github-copilot`) | +**bot**, **kb**, **memory**, **session**, **notebook**, **config / plugin / provider** — как в английском README.
- -## 🗺️ Дорожная карта - -| Статус | Этап | -|:---:|:---| -| 🔜 | **Аутентификация и вход** — опциональная страница входа для публичных развёртываний и мультипользовательский режим | -| 🔜 | **Темы и оформление** — разнообразные темы и настройка интерфейса | -| 🔜 | **Интеграция LightRAG** — подключение [LightRAG](https://github.com/HKUDS/LightRAG) как продвинутого движка баз знаний | -| 🔜 | **Сайт документации** — полная документация: руководства, справочник API и учебные материалы | - -> Если DeepTutor вам полезен, [поставьте звезду](https://github.com/HKUDS/DeepTutor/stargazers) — это помогает проекту! - ---- - ## 🌐 Сообщество и экосистема @@ -528,9 +386,6 @@ deeptutor session open ## 🤝 Участие
- -Надеемся, что DeepTutor станет подарком сообществу. 🎁 - Contributors diff --git a/assets/releases/ver1-0-0-beta-3.md b/assets/releases/ver1-0-0-beta-3.md deleted file mode 100644 index 87a498558..000000000 --- a/assets/releases/ver1-0-0-beta-3.md +++ /dev/null @@ -1,34 +0,0 @@ -# DeepTutor v1.0.0-beta.3 Release Notes - -**Release Date:** 2026.04.08 - -## Highlights - -### Remove LiteLLM Dependency -Replaced the `litellm` abstraction layer with native `openai` and `anthropic` SDKs across both the services and TutorBot layers. Added a new `OpenAICompatProvider` (covering OpenAI, DeepSeek, Mistral, StepFun, XiaoMi-MiMo, Qianfan, oVMS, and more) and a dedicated `AnthropicProvider`. The settings UI now includes a provider dropdown with auto base-URL filling. Auto-fallback to streaming is triggered when tool-call format errors occur (fixes #265). - -### Windows Math Animator Compatibility - -Fixed `SelectorEventLoop` incompatibility on Windows by replacing `asyncio.create_subprocess_exec` with `subprocess.Popen` + reader threads + `asyncio.Queue`, preserving real-time line-by-line progress output. Also applied `ProactorEventLoop` policy for subprocess support on Windows. - -### Robust JSON Parsing for LLM Outputs - -Seven agent modules (planner, idea, design, note, reporting, citation, data structures) now use `parse_json_response()` instead of raw `json.loads()`, correctly handling LLM responses wrapped in markdown code fences. A `_UNSET` sentinel was introduced for the fallback parameter so callers can explicitly request `None` as the failure value. - -### Guided Learning Fixes - -- Fixed KaTeX math rendering by configuring `$...$` and `$$...$$` delimiters, removing broken SRI integrity hashes, and adding parent-window fallback rendering for bare LaTeX text nodes. -- Fixed backend poll (`fetchPageStatuses`) overwriting user's tab navigation by only accepting `current_index` when the user hasn't navigated yet. -- Increased guide agent `max_tokens` from 8192 to 16384 to prevent HTML truncation. - -### Full Internationalization - -Completed i18n coverage for the web UI — all hardcoded strings across workspace, utility, sidebar, and component pages are now translation-keyed with full English and Chinese locale files. - -## Community Contributions - -- **@kevinmw** — Windows Math Animator renderer fix, Guided Learning KaTeX rendering & polling fix (#256, #266) -- **@LocNguyenSGU** — GitHub Copilot provider login docs (#262) -- **@kagura-agent** — `parse_json_response` for LLM outputs to handle markdown fences - -**Full Changelog**: https://github.com/HKUDS/DeepTutor/compare/v1.0.0-beta.2...v1.0.0-beta.3 diff --git a/assets/releases/ver1-0-0-beta-4.md b/assets/releases/ver1-0-0-beta-4.md deleted file mode 100644 index 4a915b042..000000000 --- a/assets/releases/ver1-0-0-beta-4.md +++ /dev/null @@ -1,21 +0,0 @@ -# DeepTutor v1.0.0-beta.4 Release Notes - -**Release Date:** 2026.04.10 - -## Highlights - -### Embedding Progress Tracking & Rate Limit Retry -Added real-time embedding progress reporting during knowledge base initialization — the UI now shows `batch N/M complete` as documents are embedded. HTTP 429 (Too Many Requests) responses are automatically retried with exponential back-off, and a configurable `batch_delay` parameter lets free-tier users throttle requests to stay within rate limits. Progress callbacks are properly cleaned up in `finally` blocks to prevent leaking into subsequent search calls. - -### Cross-Platform Start Tour Dependency Management -The onboarding start tour now auto-installs bootstrap dependencies (e.g. PyYAML) if missing, and supports system-dependency installation across macOS (Homebrew), Linux (apt/dnf/yum), and Windows (winget/Chocolatey) for Math Animator prerequisites like LaTeX, FFmpeg, Cairo, and CMake. The `typer[all]` dependency was also simplified to `typer` to avoid pulling unnecessary extras. - -### Case-Insensitive MIME Validation -Fixed a platform-dependent bug where files with uppercase extensions (e.g. `report.PDF`, `data.JSON`) bypassed MIME type validation on Linux. `mimetypes.guess_type()` now receives the lowercased filename, consistent with the extension whitelist check. - -## Community Contributions - -- **@oxkage** — Embedding progress tracking and HTTP 429 rate limit retry (#268) -- **@kuishou68** — Case-insensitive MIME type validation fix (#272, closes #271) - -**Full Changelog**: https://github.com/HKUDS/DeepTutor/compare/v1.0.0-beta.3...v1.0.0-beta.4 diff --git a/assets/releases/ver1-0-1.md b/assets/releases/ver1-0-1.md deleted file mode 100644 index 86cd37657..000000000 --- a/assets/releases/ver1-0-1.md +++ /dev/null @@ -1,26 +0,0 @@ -# DeepTutor v1.0.1 Release Notes - -**Release Date:** 2026.04.10 - -## Highlights - -### Visualize Capability with Chart.js/SVG Rendering Pipeline -Added a new **Visualize** capability that turns natural-language data descriptions into interactive Chart.js or inline SVG visualizations. The backend runs a three-stage agent pipeline (analysis → code generation → review) with bilingual prompt support (en/zh). The frontend ships two new components — `VisualizeConfigPanel` for request configuration and `VisualizationViewer` for rendering — wired into the workspace home page and chat composer. - -### Quiz Duplicate Prevention & Generation History -Fixed repeated quiz questions by introducing a dedicated `previous_questions` parameter through the Generator pipeline, cleanly separated from conversation `history_context`. A `MAX_PREVIOUS_QUESTIONS=20` cap keeps prompt size bounded, and language labels are moved into YAML templates to avoid language mixing across locales. - -### o4-mini & Future o-Series Model Support -Extended the o-series regex in LLM config to recognize `o4-mini` and future o-series model identifiers, ensuring `max_completion_tokens` is set correctly instead of the unsupported `max_tokens` parameter (closes #274). - -### Server Logging Improvements -- Suppressed noisy uvicorn WebSocket connection/disconnection logs that cluttered server output. -- Added selective HTTP access logging middleware that only logs non-200 responses, reducing log noise while preserving actionable error visibility. -- Added MiniMax model override (`supports_response_format: false`) for providers that do not support structured output. - -## Community Contributions - -- **@kuishou68** — o4-mini and future o-series model regex fix (#275, closes #274) -- **@Leadernelson** — Initial quiz duplicate question prevention (#281) - -**Full Changelog**: https://github.com/HKUDS/DeepTutor/compare/v1.0.0-beta.4...v1.0.1 diff --git a/assets/releases/ver1-0-2.md b/assets/releases/ver1-0-2.md deleted file mode 100644 index 178e2932d..000000000 --- a/assets/releases/ver1-0-2.md +++ /dev/null @@ -1,26 +0,0 @@ -# DeepTutor v1.0.2 Release Notes - -**Release Date:** 2026.04.11 - -## Highlights - -### Search Consolidation Simplification & SearXNG Fallback -Removed the explicit `consolidation_type` parameter — consolidation now runs automatically for any provider that doesn't return its own answer. A new generic fallback formatter handles providers (e.g. SearXNG) that lack a dedicated Jinja2 template, fixing the "no template consolidation available" error. The `CONSOLIDATION_TYPES` constant and related config fields have been removed. - -### Provider Switch Fix -Settings page now always overwrites `base_url` when the user selects a different provider, instead of only filling it when the field was previously empty. This prevents stale base URLs from persisting across provider changes. - -### Explicit Runtime Config in Test Runner -`ConfigTestRunner` now builds LLM, Embedding, and Search configs directly from the resolved runtime catalog instead of relying on the global config cache, ensuring test runs always reflect the current active selection. - -### Frontend Resource Leak Fixes -- Added `AbortController` cleanup across all Playground testers (ToolExecutor, DeepQuestionTester, DeepResearchTester, CapabilityTester) and the SaveToNotebookModal, preventing orphaned fetch requests on unmount or re-execution. -- Introduced a `MAX_CACHED_SESSIONS = 20` eviction policy in UnifiedChatContext to prevent unbounded session memory growth. -- WebSocket runners and retry timers are now properly cleaned up on provider unmount. -- Fixed auto-scroll throttle timer leak by returning a cleanup function from the throttle effect. - -## Community Contributions - -- **@OlegSob-glitch** — SearXNG auto-fallback for search providers without templates (#286) - -**Full Changelog**: https://github.com/HKUDS/DeepTutor/compare/v1.0.1...v1.0.2 diff --git a/deeptutor/agents/chat/agentic_pipeline.py b/deeptutor/agents/chat/agentic_pipeline.py index f879e3fcc..1ff27d46c 100644 --- a/deeptutor/agents/chat/agentic_pipeline.py +++ b/deeptutor/agents/chat/agentic_pipeline.py @@ -851,21 +851,14 @@ def _build_messages( system_prompt: str, user_content: str, ) -> list[dict[str, Any]]: - # Merge all system content into a single message to comply with Qwen vLLM requirements - system_parts = [system_prompt] + messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] if context.memory_context: - system_parts.append(context.memory_context) - system_content = "\n\n".join(system_parts) - - messages: list[dict[str, Any]] = [{"role": "system", "content": system_content}] - - # Add conversation history (filter out system messages to avoid duplicates) + messages.append({"role": "system", "content": context.memory_context}) for item in context.conversation_history: role = item.get("role") content = item.get("content") - if role in {"user", "assistant"} and isinstance(content, (str, list)): + if role in {"user", "assistant", "system"} and isinstance(content, (str, list)): messages.append({"role": role, "content": content}) - messages.append({"role": "user", "content": user_content}) return messages diff --git a/deeptutor/agents/chat/chat_agent.py b/deeptutor/agents/chat/chat_agent.py index aa1cf1369..7d31a72dd 100644 --- a/deeptutor/agents/chat/chat_agent.py +++ b/deeptutor/agents/chat/chat_agent.py @@ -245,13 +245,15 @@ def build_messages( """ messages = [] - # Merge system prompt and context into single system message to comply with Qwen vLLM requirements - system_parts = [self.get_prompt("system", "You are a helpful AI assistant.")] + # System prompt + system_prompt = self.get_prompt("system", "You are a helpful AI assistant.") + messages.append({"role": "system", "content": system_prompt}) + + # Add context if available if context: context_template = self.get_prompt("context_template", "Reference context:\n{context}") - system_parts.append(context_template.format(context=context)) - system_content = "\n\n".join(system_parts) - messages.append({"role": "system", "content": system_content}) + context_msg = context_template.format(context=context) + messages.append({"role": "system", "content": context_msg}) # Add conversation history for msg in history: diff --git a/deeptutor/agents/goal/__init__.py b/deeptutor/agents/goal/__init__.py new file mode 100644 index 000000000..6742fee3d --- /dev/null +++ b/deeptutor/agents/goal/__init__.py @@ -0,0 +1,25 @@ +"""Goal-oriented adaptive learning mode.""" + +from .models import ( + Feedback, + GoalConfig, + GoalSession, + KnowledgeEdge, + KnowledgeNode, + KnowledgeNodeDraft, + Plan, + Task, +) +from .storage import GoalStorage + +__all__ = [ + "Feedback", + "GoalConfig", + "GoalSession", + "GoalStorage", + "KnowledgeEdge", + "KnowledgeNode", + "KnowledgeNodeDraft", + "Plan", + "Task", +] diff --git a/deeptutor/agents/goal/exam_miner.py b/deeptutor/agents/goal/exam_miner.py new file mode 100644 index 000000000..4874efcc6 --- /dev/null +++ b/deeptutor/agents/goal/exam_miner.py @@ -0,0 +1,240 @@ +"""Exam material analyzer for goal mode.""" + +from __future__ import annotations + +import base64 +from collections import Counter +import json +import re +from typing import Any + +from deeptutor.services.llm import complete as llm_complete +from deeptutor.utils.json_parser import parse_json_response + + +class ExamMiner: + """Extract high-frequency exam patterns from mixed materials.""" + + async def analyze( + self, + *, + pasted_text: str, + file_texts: list[dict[str, str]], + image_files: list[dict[str, Any]], + language: str = "en", + ) -> dict[str, Any]: + text_chunks = [] + if pasted_text.strip(): + text_chunks.append({"source": "pasted_text", "text": pasted_text.strip()}) + text_chunks.extend(file_texts) + + image_texts = await self._extract_image_texts(image_files) + text_chunks.extend(image_texts) + + corpus = "\n\n".join( + f"[SOURCE: {item.get('source', 'unknown')}]\n{item.get('text', '').strip()}" + for item in text_chunks + if item.get("text", "").strip() + ).strip() + if not corpus: + return { + "question_count": 0, + "knowledge_points": [], + "question_types": [], + "difficulty_distribution": {}, + "insights": ["No valid question text was extracted. Upload clearer materials or paste the original questions."], + "samples": [], + "source_breakdown": {"text_sources": len(file_texts), "image_sources": len(image_files)}, + } + + extracted = await self._extract_structured_questions(corpus, language) + questions = self._normalize_questions(extracted.get("questions", [])) + if not questions: + questions = self._fallback_questions(corpus) + + knowledge_counter = Counter() + type_counter = Counter() + diff_counter = Counter() + for question in questions: + q_type = str(question.get("question_type", "")).strip() or "Unclassified" + difficulty = str(question.get("difficulty", "")).strip() or "unknown" + type_counter[q_type] += 1 + diff_counter[difficulty] += 1 + for point in question.get("knowledge_points", []): + normalized = str(point).strip() + if normalized: + knowledge_counter[normalized] += 1 + + total = max(1, len(questions)) + top_knowledge = [ + { + "name": name, + "count": count, + "ratio": round(count / total, 4), + } + for name, count in knowledge_counter.most_common(12) + ] + top_types = [ + { + "name": name, + "count": count, + "ratio": round(count / total, 4), + } + for name, count in type_counter.most_common(8) + ] + + insights = [] + if top_knowledge: + insights.append(f"Top 3 high-yield knowledge points: {', '.join(item['name'] for item in top_knowledge[:3])}") + if top_types: + insights.append(f"Top 3 high-yield question types: {', '.join(item['name'] for item in top_types[:3])}") + if len(questions) < 8: + insights.append("Sample size is small (<8). Add more past papers from the last 3-5 years for better stability.") + else: + insights.append("Sample size passed the basic statistical threshold and can support exam-prep prioritization.") + + return { + "question_count": len(questions), + "knowledge_points": top_knowledge, + "question_types": top_types, + "difficulty_distribution": dict(diff_counter), + "insights": insights, + "samples": [q.get("stem", "") for q in questions[:5]], + "source_breakdown": { + "text_sources": len(file_texts) + (1 if pasted_text.strip() else 0), + "image_sources": len(image_files), + }, + } + + async def _extract_image_texts(self, image_files: list[dict[str, Any]]) -> list[dict[str, str]]: + outputs: list[dict[str, str]] = [] + for image in image_files[:10]: + data = image.get("data") + if not isinstance(data, (bytes, bytearray)): + continue + mime = str(image.get("content_type", "image/png") or "image/png") + base64_data = base64.b64encode(bytes(data)).decode("utf-8") + data_url = f"data:{mime};base64,{base64_data}" + try: + response = await llm_complete( + prompt="", + system_prompt="", + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": ( + "You are an OCR and question-structure assistant. Read question text from the image and output plain text only." + "Do not explain, do not add content, and do not use markdown. If unclear, output only the readable part." + ), + }, + {"type": "image_url", "image_url": {"url": data_url}}, + ], + } + ], + temperature=0.0, + max_tokens=1400, + ) + text = str(response or "").strip() + if text: + outputs.append({"source": image.get("name", "image"), "text": text}) + except Exception: + continue + return outputs + + async def _extract_structured_questions(self, corpus: str, language: str) -> dict[str, Any]: + bounded = corpus[:36000] + system_prompt = ( + "You are an exam data annotator. Extract questions from materials and return structured JSON only." + ) + user_prompt = ( + f"[Material]\n{bounded}\n\n" + "Return JSON with key `questions`, where each item has stem, question_type, knowledge_points, difficulty." + ) + raw = await llm_complete( + prompt=user_prompt, + system_prompt=system_prompt, + temperature=0.1, + max_tokens=2000, + response_format={"type": "json_object"}, + ) + parsed = parse_json_response(raw, fallback={"questions": []}) + return parsed if isinstance(parsed, dict) else {"questions": []} + + def _normalize_questions(self, questions: list[Any]) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + for item in questions: + if not isinstance(item, dict): + continue + stem = re.sub(r"\s+", " ", str(item.get("stem", "")).strip()) + if len(stem) < 6: + continue + q_type = str(item.get("question_type", "")).strip() or "Unclassified" + difficulty = str(item.get("difficulty", "")).strip().lower() + if difficulty not in {"easy", "medium", "hard"}: + difficulty = "medium" + points_raw = item.get("knowledge_points", []) + if isinstance(points_raw, list): + points = [str(value).strip() for value in points_raw if str(value).strip()] + else: + points = [str(points_raw).strip()] if str(points_raw).strip() else [] + normalized.append( + { + "stem": stem, + "question_type": q_type, + "knowledge_points": points[:5], + "difficulty": difficulty, + } + ) + return normalized + + def _fallback_questions(self, corpus: str) -> list[dict[str, Any]]: + blocks = [ + block.strip() + for block in re.split(r"(?:\n\s*\n|(?=\n?\d+[\.、\)])|(?=\n?第?\d+题))", corpus) + if block.strip() + ] + questions: list[dict[str, Any]] = [] + for block in blocks[:30]: + text = re.sub(r"\s+", " ", block) + if len(text) < 10: + continue + questions.append( + { + "stem": text[:220], + "question_type": self._infer_question_type(text), + "knowledge_points": self._guess_points(text), + "difficulty": "medium", + } + ) + return questions + + def _infer_question_type(self, text: str) -> str: + lowered = text.lower() + if re.search(r"证明|prove", lowered): + return "Proof" + if re.search(r"选择|单选|多选|option|a\.|b\.", lowered): + return "Multiple Choice" + if re.search(r"编程|代码|program|java|python|c\+\+", lowered): + return "Programming" + if re.search(r"计算|求|解|evaluate|compute", lowered): + return "Computation" + return "Short Answer" + + def _guess_points(self, text: str) -> list[str]: + candidates = re.findall(r"[\u4e00-\u9fffA-Za-z]{2,12}", text) + stopwords = {"以下", "关于", "已知", "其中", "并且", "的是", "进行", "给出", "题目", "请问"} + points: list[str] = [] + for token in candidates: + if token in stopwords or token.isdigit(): + continue + if token not in points: + points.append(token) + if len(points) >= 4: + break + return points + + +__all__ = ["ExamMiner"] diff --git a/deeptutor/agents/goal/exercise_adapter.py b/deeptutor/agents/goal/exercise_adapter.py new file mode 100644 index 000000000..9da8171ff --- /dev/null +++ b/deeptutor/agents/goal/exercise_adapter.py @@ -0,0 +1,73 @@ +"""Practice generation adapter for Goal mode.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from deeptutor.agents.question import AgentCoordinator +from deeptutor.agents.goal.models import Task +from deeptutor.services.llm.config import get_llm_config +from deeptutor.services.settings.interface_settings import get_ui_language + + +class ExerciseAdapter: + """Persist a lightweight practice set for a task. + + Goal mode prefers the existing DeepTutor question pipeline. If the + coordinator is unavailable or generation fails, it falls back to a + deterministic local payload so the user still gets a usable artifact. + """ + + async def generate_for_task(self, task: Task, kb_name: str, output_dir: str | Path) -> dict: + target_dir = Path(output_dir) + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / f"{task.task_id}.json" + payload = await self._generate_payload(task=task, kb_name=kb_name) + target.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + return {"practice_set_path": str(target), "practice_set": payload} + + async def _generate_payload(self, task: Task, kb_name: str) -> dict: + try: + llm_config = get_llm_config() + coordinator = AgentCoordinator( + api_key=llm_config.api_key, + base_url=llm_config.base_url, + api_version=getattr(llm_config, "api_version", None), + kb_name=kb_name, + language=get_ui_language(default="zh"), + ) + result = await coordinator.generate_from_topic( + user_topic=task.title, + preference=task.objective, + num_questions=task.practice_spec.count if task.practice_spec else 1, + difficulty=task.practice_spec.difficulty if task.practice_spec else "easy", + question_type=task.practice_spec.question_type if task.practice_spec else "short_answer", + ) + if result.get("results"): + return { + "task_id": task.task_id, + "kb_name": kb_name, + "source": "deeptutor_question", + "questions": [item.get("qa_pair", {}) for item in result["results"]], + "trace": result.get("trace", {}), + } + except Exception: + pass + + return self._build_fallback_payload(task=task, kb_name=kb_name) + + def _build_fallback_payload(self, task: Task, kb_name: str) -> dict: + return { + "task_id": task.task_id, + "kb_name": kb_name, + "source": "fallback", + "questions": [ + { + "prompt": f"请解释 {task.title} 的核心概念。", + "question_type": (task.practice_spec.question_type if task.practice_spec else "short_answer"), + "difficulty": (task.practice_spec.difficulty if task.practice_spec else "easy"), + "reference_answer": task.objective, + } + ], + } diff --git a/deeptutor/agents/goal/extractor.py b/deeptutor/agents/goal/extractor.py new file mode 100644 index 000000000..92b632257 --- /dev/null +++ b/deeptutor/agents/goal/extractor.py @@ -0,0 +1,267 @@ +"""Rule-based knowledge extraction for Goal mode.""" + +from __future__ import annotations + +import json +from pathlib import Path +import re + +from deeptutor.agents.goal.models import KnowledgeNodeDraft, WeightEvidence +from deeptutor.knowledge.manager import KnowledgeBaseManager +from deeptutor.services.config import PROJECT_ROOT + + +class KnowledgeExtractor: + """Extract candidate knowledge nodes from an existing knowledge base.""" + + def __init__(self, kb_base_dir: str | Path | None = None): + base_dir = Path(kb_base_dir) if kb_base_dir is not None else PROJECT_ROOT / "data" / "knowledge_bases" + self.manager = KnowledgeBaseManager(base_dir=str(base_dir)) + + async def extract( + self, + kb_name: str, + scope: dict | None = None, + ) -> list[KnowledgeNodeDraft]: + try: + kb_dir = self.manager.get_knowledge_base_path(kb_name) + except Exception: + return self._fallback_drafts(scope or {}, kb_name) + raw_dir = kb_dir / "raw" + content_dir = kb_dir / "content_list" + + files = self._collect_source_files(raw_dir, content_dir) + drafts: list[KnowledgeNodeDraft] = [] + for path in files: + text = self._read_text(path) + if not text.strip(): + continue + drafts.extend(self._extract_from_text(path, text, scope or {})) + deduped = self._dedupe(drafts) + if deduped: + return deduped + return self._fallback_drafts(scope or {}, kb_name) + + def _collect_source_files(self, raw_dir: Path, content_dir: Path) -> list[Path]: + files: list[Path] = [] + for directory in (content_dir, raw_dir): + if not directory.exists(): + continue + for path in directory.rglob("*"): + if path.is_file() and path.suffix.lower() in {".md", ".txt", ".json"}: + files.append(path) + return sorted(files) + + def _read_text(self, path: Path) -> str: + if path.suffix.lower() == ".json": + try: + data = json.loads(path.read_text(encoding="utf-8")) + return json.dumps(data, ensure_ascii=False) + except Exception: + return "" + try: + return path.read_text(encoding="utf-8") + except UnicodeDecodeError: + return path.read_text(encoding="utf-8", errors="ignore") + + def _extract_from_text( + self, + path: Path, + text: str, + scope: dict, + ) -> list[KnowledgeNodeDraft]: + lines = [line.strip() for line in text.splitlines() if line.strip()] + selected_scope = set(scope.get("chapters") or []) | set(scope.get("topics") or []) + excluded = set(scope.get("exclude_topics") or []) + + drafts: list[KnowledgeNodeDraft] = [] + for index, line in enumerate(lines): + normalized = self._normalize_title(line) + if not normalized or normalized in excluded: + continue + if selected_scope and normalized not in selected_scope and line not in selected_scope: + if not self._looks_like_heading(line): + continue + + evidence = WeightEvidence( + source=str(path.relative_to(path.parents[1])) if len(path.parents) > 1 else path.name, + snippet=self._build_snippet(lines, index), + ) + practice_freq = 0.0 + if re.search(r"真题|模拟|练习|题型|刷题|exam|mock|practice", line, re.IGNORECASE): + practice_freq = 1.0 + elif re.search(r"例题|习题|训练", line, re.IGNORECASE): + practice_freq = 0.6 + drafts.append( + KnowledgeNodeDraft( + title=normalized, + aliases=[line] if line != normalized else [], + evidence=[evidence], + tags=self._build_tags(line), + estimated_minutes=30 if self._looks_like_heading(line) else 20, + doc_freq=1.0, + practice_freq=practice_freq, + heading_score=1.0 if self._looks_like_heading(line) else 0.5, + ) + ) + + if not drafts: + fallback = " ".join(lines[:8]).strip() + if fallback: + drafts.append( + KnowledgeNodeDraft( + title=path.stem, + evidence=[WeightEvidence(source=path.name, snippet=fallback[:240])], + tags=["document"], + ) + ) + return drafts + + def _build_tags(self, line: str) -> list[str]: + tags: list[str] = [] + if re.search(r"基础|basic|introduction", line, re.IGNORECASE): + tags.append("foundation") + if re.search(r"重点|核心|high[- ]yield", line, re.IGNORECASE): + tags.append("high_yield") + if re.search(r"真题|历年|模拟|exam|mock", line, re.IGNORECASE): + tags.append("exam") + return tags + + def _build_snippet(self, lines: list[str], index: int) -> str: + window = lines[index : index + 3] + return " ".join(window)[:240] + + def _normalize_title(self, line: str) -> str: + value = re.sub(r"^\s*(#+|\d+[\.\)]|[-*])\s*", "", line).strip(" ::-") + if len(value) < 2: + return "" + return value[:80] + + def _looks_like_heading(self, line: str) -> bool: + return bool(re.match(r"^\s*(#+|\d+[\.\)]|第.+[章节])", line)) or len(line) <= 30 + + def _dedupe(self, drafts: list[KnowledgeNodeDraft]) -> list[KnowledgeNodeDraft]: + merged: dict[str, KnowledgeNodeDraft] = {} + for draft in drafts: + key = draft.title.lower() + current = merged.get(key) + if current is None: + merged[key] = draft + continue + current.aliases = sorted(set(current.aliases + draft.aliases)) + current.tags = sorted(set(current.tags + draft.tags)) + current.evidence.extend(draft.evidence) + current.doc_freq += draft.doc_freq + current.heading_score = max(current.heading_score, draft.heading_score) + return list(merged.values()) + + def _fallback_drafts(self, scope: dict, kb_name: str) -> list[KnowledgeNodeDraft]: + seeds = list(scope.get("chapters") or []) + list(scope.get("topics") or []) + goal_statement = str(scope.get("goal_statement") or "").strip() + goal_seeds = self._infer_goal_seeds(goal_statement) + for seed in goal_seeds: + if seed not in seeds: + seeds.append(seed) + if not seeds: + seeds = [ + "Core concept review", + "High-yield question type training", + "Comprehensive review and self-check", + ] + drafts: list[KnowledgeNodeDraft] = [] + previous_title: str | None = None + for index, seed in enumerate(seeds[:8], start=1): + drafts.append( + KnowledgeNodeDraft( + title=seed, + aliases=[], + evidence=[ + WeightEvidence( + source=f"fallback:{kb_name}", + snippet="Knowledge base unavailable; using scaffolded goal plan.", + ) + ], + tags=self._fallback_tags(seed, index), + estimated_minutes=30 + (index - 1) * 5, + doc_freq=max(0.2, 1.0 - (index - 1) * 0.1), + practice_freq=0.8 if re.search(r"past paper|question type|training|self-check|error", seed, re.IGNORECASE) else 0.3, + heading_score=0.6, + prerequisites=[previous_title] if previous_title else [], + ) + ) + previous_title = seed + return drafts + + def _infer_goal_seeds(self, goal_statement: str) -> list[str]: + if not goal_statement: + return [] + + text = goal_statement.lower() + seeds: list[str] = [] + + # Domain priors: provide a concrete plan skeleton from common subject knowledge. + if any(token in text for token in ("微积分", "calculus", "导数", "积分", "极限")): + seeds.extend( + [ + "Limits and continuity", + "Derivatives and differentiation rules", + "Derivative applications (monotonicity and extrema)", + "Indefinite integrals and integration techniques", + "Definite integrals and geometric applications", + "Comprehensive review and self-check", + ] + ) + elif any(token in text for token in ("线代", "线性代数", "linear algebra", "矩阵", "特征值")): + seeds.extend( + [ + "Matrices and linear systems", + "Vector spaces and linear dependence", + "Eigenvalues and eigenvectors", + "Orthogonalization and quadratic forms", + "Comprehensive question type training", + ] + ) + elif any(token in text for token in ("概率", "统计", "probability", "statistics")): + seeds.extend( + [ + "Random variables and distributions", + "Expectation, variance, and common inequalities", + "Conditional probability and Bayes", + "Parameter estimation and hypothesis testing", + "Comprehensive question type training", + ] + ) + + # Exam-aware enrichment for real past papers / mock questions. + if any(token in text for token in ("真题", "历年", "模拟题", "试卷", "past paper", "mock")): + seeds.extend( + [ + "High-yield past paper pattern breakdown", + "Error-bank retraining and variation practice", + ] + ) + + # Generic extraction fallback from long goal text phrases. + if not seeds: + for chunk in re.split(r"[,,。;;、\n]+", goal_statement): + value = chunk.strip() + if 2 <= len(value) <= 24: + seeds.append(value) + if len(seeds) >= 6: + break + + # De-duplicate while preserving order. + deduped: list[str] = [] + for seed in seeds: + if seed and seed not in deduped: + deduped.append(seed) + return deduped[:8] + + def _fallback_tags(self, seed: str, index: int) -> list[str]: + tags = ["fallback"] + if index == 1: + tags.append("foundation") + if re.search(r"真题|题型|训练|错题|自测", seed): + tags.append("high_yield") + tags.append("exam") + return tags diff --git a/deeptutor/agents/goal/graph_builder.py b/deeptutor/agents/goal/graph_builder.py new file mode 100644 index 000000000..8099a2539 --- /dev/null +++ b/deeptutor/agents/goal/graph_builder.py @@ -0,0 +1,65 @@ +"""Graph construction for Goal mode.""" + +from __future__ import annotations + +from deeptutor.agents.goal.models import EdgeType, KnowledgeEdge, KnowledgeNode, KnowledgeNodeDraft +from deeptutor.agents.goal.utils import slugify + + +class GraphBuilder: + """Convert drafts into a lightweight knowledge graph.""" + + def build( + self, + drafts: list[KnowledgeNodeDraft], + ) -> tuple[list[KnowledgeNode], list[KnowledgeEdge]]: + nodes: list[KnowledgeNode] = [] + edges: list[KnowledgeEdge] = [] + + for index, draft in enumerate(drafts): + node_id = f"node_{slugify(draft.title)}" + prerequisites = [f"node_{slugify(title)}" for title in draft.prerequisites] + nodes.append( + KnowledgeNode( + node_id=node_id, + title=draft.title, + aliases=draft.aliases, + node_type=draft.node_type, + tags=draft.tags, + prerequisites=prerequisites, + estimated_minutes=draft.estimated_minutes, + weight_evidence=draft.evidence, + signals={ + "doc_freq": draft.doc_freq, + "practice_freq": draft.practice_freq, + "heading_score": draft.heading_score, + "weakness_score": draft.weakness_score, + }, + ) + ) + + if index > 0: + edges.append( + KnowledgeEdge( + **{ + "from": nodes[index - 1].node_id, + "to": node_id, + "edge_type": EdgeType.RELATED, + "weight": 0.5, + } + ) + ) + + for prerequisite in prerequisites: + edges.append( + KnowledgeEdge( + **{ + "from": prerequisite, + "to": node_id, + "edge_type": EdgeType.PREREQUISITE, + "weight": 1.0, + } + ) + ) + + return nodes, edges diff --git a/deeptutor/agents/goal/models.py b/deeptutor/agents/goal/models.py new file mode 100644 index 000000000..2416fb4d0 --- /dev/null +++ b/deeptutor/agents/goal/models.py @@ -0,0 +1,377 @@ +"""Pydantic models for Goal-Oriented Adaptive Learning Mode.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +class GoalLevel(str, Enum): + FOUNDATION = "foundation" + COMPETENT = "competent" + ADVANCED = "advanced" + + +class PlanningStrategy(str, Enum): + DEPTH_FIRST = "depth_first" + BREADTH_FIRST = "breadth_first" + HIGH_YIELD_FIRST = "high_yield_first" + + +class SessionStatus(str, Enum): + CREATED = "created" + RUNNING = "running" + READY = "ready" + FAILED = "failed" + + +class PlanStatus(str, Enum): + DRAFT = "draft" + READY = "ready" + FAILED = "failed" + + +class NodeType(str, Enum): + CONCEPT = "concept" + CHAPTER = "chapter" + SKILL = "skill" + + +class EdgeType(str, Enum): + PREREQUISITE = "prerequisite" + RELATED = "related" + CONTAINS = "contains" + + +class TaskKind(str, Enum): + LEARN = "learn" + PRACTICE = "practice" + REVIEW = "review" + QUIZ = "quiz" + + +class TaskStatus(str, Enum): + PENDING = "pending" + DONE = "done" + SKIPPED = "skipped" + + +class CompletionStatus(str, Enum): + DONE = "done" + PARTIAL = "partial" + MISSED = "missed" + SKIPPED = "skipped" + + +class ScopeConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + chapters: list[str] = Field(default_factory=list) + topics: list[str] = Field(default_factory=list) + exclude_topics: list[str] = Field(default_factory=list) + + +class PreferencesConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + strategy: PlanningStrategy = PlanningStrategy.HIGH_YIELD_FIRST + include_practice: bool = True + practice_ratio: float = 0.4 + review_ratio: float = 0.2 + language: str = "zh" + + @field_validator("practice_ratio", "review_ratio") + @classmethod + def validate_ratio(cls, value: float) -> float: + if not 0 <= value <= 1: + raise ValueError("ratio must be between 0 and 1") + return value + + +class GoalConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + goal_level: GoalLevel + remaining_days: int = 7 + daily_minutes: int + days_per_week: int = 7 + kb_name: str | None = None + goal_statement: str = "" + scope: ScopeConfig = Field(default_factory=ScopeConfig) + preferences: PreferencesConfig = Field(default_factory=PreferencesConfig) + + @field_validator("remaining_days", "daily_minutes", "days_per_week") + @classmethod + def positive_int(cls, value: int) -> int: + if value <= 0: + raise ValueError("must be > 0") + return value + + @field_validator("days_per_week") + @classmethod + def validate_days_per_week(cls, value: int) -> int: + if value > 7: + raise ValueError("days_per_week must be <= 7") + return value + + +class GoalMetrics(BaseModel): + model_config = ConfigDict(extra="forbid") + + completion_rate: float = 0.0 + avg_quiz_accuracy: float = 0.0 + replan_count: int = 0 + + +class GoalArtifacts(BaseModel): + model_config = ConfigDict(extra="forbid") + + root_dir: str + session_json: str = "session.json" + plan_json: str | None = None + graph_json: str | None = None + events_jsonl: str = "events.jsonl" + + +class GoalSession(BaseModel): + model_config = ConfigDict(extra="forbid") + + session_id: str + kb_name: str + goal_config: GoalConfig + status: SessionStatus = SessionStatus.CREATED + plan_id: str | None = None + plan_version: int = 0 + current_day_index: int = 1 + metrics: GoalMetrics = Field(default_factory=GoalMetrics) + artifacts: GoalArtifacts + + @field_validator("plan_version", "current_day_index") + @classmethod + def non_negative_int(cls, value: int) -> int: + if value < 0: + raise ValueError("must be >= 0") + return value + + +class WeightEvidence(BaseModel): + model_config = ConfigDict(extra="forbid") + + source: str + snippet: str + + +class KnowledgeNodeDraft(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str + aliases: list[str] = Field(default_factory=list) + evidence: list[WeightEvidence] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + node_type: NodeType = NodeType.CONCEPT + estimated_minutes: int = 30 + doc_freq: float = 0.0 + practice_freq: float = 0.0 + heading_score: float = 0.0 + weakness_score: float = 0.0 + prerequisites: list[str] = Field(default_factory=list) + + +class KnowledgeNode(BaseModel): + model_config = ConfigDict(extra="forbid") + + node_id: str + title: str + aliases: list[str] = Field(default_factory=list) + node_type: NodeType = NodeType.CONCEPT + tags: list[str] = Field(default_factory=list) + prerequisites: list[str] = Field(default_factory=list) + estimated_minutes: int = 30 + weight: float = 0.0 + weight_evidence: list[WeightEvidence] = Field(default_factory=list) + signals: dict[str, float] = Field(default_factory=dict) + + @field_validator("estimated_minutes") + @classmethod + def validate_minutes(cls, value: int) -> int: + if value <= 0: + raise ValueError("estimated_minutes must be > 0") + return value + + +class KnowledgeEdge(BaseModel): + model_config = ConfigDict(extra="forbid") + + from_: str = Field(alias="from") + to: str + edge_type: EdgeType + weight: float = 1.0 + + +class TaskResource(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: str + ref: str + + +class PracticeSpec(BaseModel): + model_config = ConfigDict(extra="forbid") + + difficulty: str + question_type: str + count: int + + +class Task(BaseModel): + model_config = ConfigDict(extra="forbid") + + task_id: str + day_index: int + node_id: str + title: str + kind: TaskKind + objective: str + estimate_minutes: int + priority: int = 1 + resources: list[TaskResource] = Field(default_factory=list) + practice_spec: PracticeSpec | None = None + status: TaskStatus = TaskStatus.PENDING + outputs: list[str] = Field(default_factory=list) + optional: bool = False + + @field_validator("day_index", "estimate_minutes") + @classmethod + def positive_task_ints(cls, value: int) -> int: + if value <= 0: + raise ValueError("must be > 0") + return value + + +class QuizResult(BaseModel): + model_config = ConfigDict(extra="forbid") + + score: int + total: int + + @model_validator(mode="after") + def validate_score(self) -> "QuizResult": + if self.total <= 0: + raise ValueError("total must be > 0") + if self.score < 0 or self.score > self.total: + raise ValueError("score must be between 0 and total") + return self + + +class Feedback(BaseModel): + model_config = ConfigDict(extra="forbid") + + feedback_id: str + session_id: str + task_id: str + completion: CompletionStatus + actual_minutes: int | None = None + quiz: QuizResult | None = None + reflection: str = "" + timestamp: str + + @field_validator("actual_minutes") + @classmethod + def validate_actual_minutes(cls, value: int | None) -> int | None: + if value is not None and value < 0: + raise ValueError("actual_minutes must be >= 0") + return value + + +class PlanDay(BaseModel): + model_config = ConfigDict(extra="forbid") + + day_index: int + date: str | None = None + budget_minutes: int + task_ids: list[str] = Field(default_factory=list) + notes: str = "" + + +class DayPlanTimeBlock(BaseModel): + model_config = ConfigDict(extra="forbid") + + block_id: str + title: str + kind: TaskKind + minutes: int + steps: list[str] = Field(default_factory=list) + linked_task_ids: list[str] = Field(default_factory=list) + + +class DayPlanDetail(BaseModel): + model_config = ConfigDict(extra="forbid") + + session_id: str + day_index: int + date: str | None = None + objective_summary: str + time_blocks: list[DayPlanTimeBlock] = Field(default_factory=list) + key_points: list[str] = Field(default_factory=list) + pitfalls: list[str] = Field(default_factory=list) + acceptance_criteria: list[str] = Field(default_factory=list) + review_actions: list[str] = Field(default_factory=list) + linked_task_ids: list[str] = Field(default_factory=list) + + +class PlanDiff(BaseModel): + model_config = ConfigDict(extra="forbid") + + added_tasks: list[str] = Field(default_factory=list) + moved_tasks: list[str] = Field(default_factory=list) + dropped_tasks: list[str] = Field(default_factory=list) + + +class PlanArtifacts(BaseModel): + model_config = ConfigDict(extra="forbid") + + root_dir: str + plan_json: str = "plan.json" + graph_json: str = "graph.json" + events_jsonl: str = "events.jsonl" + + +class Plan(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + plan_id: str + session_id: str + kb_name: str + plan_version: int + status: PlanStatus = PlanStatus.READY + knowledge_nodes: list[KnowledgeNode] = Field(default_factory=list) + knowledge_edges: list[KnowledgeEdge] = Field(default_factory=list) + days: list[PlanDay] + tasks: list[Task] + diff: PlanDiff | None = None + artifacts: PlanArtifacts + + @field_validator("plan_version") + @classmethod + def validate_plan_version(cls, value: int) -> int: + if value < 0: + raise ValueError("plan_version must be >= 0") + return value + + @model_validator(mode="after") + def ensure_required_collections(self) -> "Plan": + if not self.session_id: + raise ValueError("session_id is required") + if self.days is None: + raise ValueError("days is required") + if self.tasks is None: + raise ValueError("tasks is required") + return self + + +def model_to_dict(model: BaseModel) -> dict[str, Any]: + """Serialize a pydantic model using JSON-compatible values.""" + + return model.model_dump(mode="json", by_alias=True) diff --git a/deeptutor/agents/goal/orchestrator.py b/deeptutor/agents/goal/orchestrator.py new file mode 100644 index 000000000..91b38a5fe --- /dev/null +++ b/deeptutor/agents/goal/orchestrator.py @@ -0,0 +1,1122 @@ +"""Goal mode orchestration.""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime +import json +import io +import re +from pathlib import Path +from typing import Any + +from deeptutor.agents.goal.exercise_adapter import ExerciseAdapter +from deeptutor.agents.goal.exam_miner import ExamMiner +from deeptutor.agents.goal.extractor import KnowledgeExtractor +from deeptutor.agents.goal.graph_builder import GraphBuilder +from deeptutor.agents.goal.models import ( + DayPlanDetail, + DayPlanTimeBlock, + Feedback, + GoalArtifacts, + GoalConfig, + GoalSession, + Plan, + PlanArtifacts, + PlanDay, + SessionStatus, + Task, + TaskKind, + TaskStatus, +) +from deeptutor.agents.goal.planner import Planner +from deeptutor.agents.goal.scheduler import Scheduler +from deeptutor.agents.goal.storage import GoalStorage +from deeptutor.agents.goal.utils import build_session_id, future_dates +from deeptutor.agents.goal.weight_model import WeightModel +from deeptutor.agents.guide.agents.interactive_agent import InteractiveAgent +from deeptutor.services.config import parse_language +from deeptutor.services.llm import complete as llm_complete +from deeptutor.services.llm import get_llm_config + + +class GoalOrchestrator: + """Coordinate session creation, planning, feedback, and practice generation.""" + + def __init__( + self, + storage: GoalStorage | None = None, + extractor: KnowledgeExtractor | None = None, + graph_builder: GraphBuilder | None = None, + weight_model: WeightModel | None = None, + planner: Planner | None = None, + scheduler: Scheduler | None = None, + exercise_adapter: ExerciseAdapter | None = None, + exam_miner: ExamMiner | None = None, + ): + self.storage = storage or GoalStorage() + self.extractor = extractor or KnowledgeExtractor() + self.graph_builder = graph_builder or GraphBuilder() + self.weight_model = weight_model or WeightModel() + self.planner = planner or Planner() + self.scheduler = scheduler or Scheduler() + self.exercise_adapter = exercise_adapter or ExerciseAdapter() + self.exam_miner = exam_miner or ExamMiner() + self._feedback_store: dict[str, list[Feedback]] = defaultdict(list) + + async def create_session(self, kb_name: str, goal_config: GoalConfig) -> GoalSession: + sequence = 1 + while (self.storage.base_dir / build_session_id(sequence=sequence) / "session.json").exists(): + sequence += 1 + session_id = build_session_id(sequence=sequence) + session = GoalSession( + session_id=session_id, + kb_name=kb_name, + goal_config=goal_config.model_copy(update={"kb_name": kb_name}), + artifacts=GoalArtifacts(root_dir=f"data/user/goal/{session_id}"), + ) + self.storage.save_session(session) + self.storage.append_event( + session_id, + {"ts": datetime.now().isoformat(), "type": "stage", "stage": "created", "progress": 1.0}, + ) + return session + + async def run_plan(self, session_id: str) -> Plan: + session = self.storage.load_session(session_id) + self.storage.append_event(session_id, self._stage_event("extract", 0.1)) + extraction_scope = session.goal_config.scope.model_dump(mode="json") + extraction_scope["goal_statement"] = session.goal_config.goal_statement + drafts = await self.extractor.extract(session.kb_name, extraction_scope) + + self.storage.append_event(session_id, self._stage_event("graph", 0.4)) + nodes, edges = self.graph_builder.build(drafts) + scored_nodes = self.weight_model.score(nodes, edges, session.goal_config) + + self.storage.append_event(session_id, self._stage_event("plan", 0.7)) + tasks = self.planner.plan(scored_nodes, edges, session.goal_config) + days = self._build_days(session.goal_config.daily_minutes, session.goal_config.remaining_days, tasks) + + plan = Plan( + plan_id=f"plan_{session.plan_version + 1:03d}", + session_id=session.session_id, + kb_name=session.kb_name, + plan_version=session.plan_version + 1, + knowledge_nodes=scored_nodes, + knowledge_edges=edges, + days=days, + tasks=tasks, + artifacts=PlanArtifacts(root_dir=session.artifacts.root_dir), + ) + self.storage.save_graph(session_id, scored_nodes, edges) + self.storage.save_plan(plan) + + session.plan_id = plan.plan_id + session.plan_version = plan.plan_version + session.status = SessionStatus.READY + session.artifacts.plan_json = plan.artifacts.plan_json + session.artifacts.graph_json = plan.artifacts.graph_json + self.storage.save_session(session) + self.storage.append_event(session_id, self._stage_event("complete", 1.0)) + return plan + + async def submit_feedback(self, session_id: str, feedback: Feedback) -> dict: + self._feedback_store[session_id].append(feedback) + try: + plan = self.storage.load_plan(session_id) + for task in plan.tasks: + if task.task_id != feedback.task_id: + continue + if feedback.completion.value == "done": + task.status = TaskStatus.DONE + elif feedback.completion.value == "skipped": + task.status = TaskStatus.SKIPPED + else: + task.status = TaskStatus.PENDING + break + self.storage.save_plan(plan) + except FileNotFoundError: + # Feedback can still be accepted before any plan is generated. + pass + self.storage.append_event( + session_id, + {"ts": datetime.now().isoformat(), "type": "feedback", "task_id": feedback.task_id}, + ) + return {"accepted": True, "next_action": "none"} + + async def replan(self, session_id: str, reason: str | None = None) -> Plan: + plan = self.storage.load_plan(session_id) + feedback_batch = self._feedback_store.get(session_id, []) + new_plan, diff = self.scheduler.replan(plan, feedback_batch) + self.storage.save_plan(new_plan) + + session = self.storage.load_session(session_id) + session.plan_version = new_plan.plan_version + session.metrics.replan_count += 1 + self.storage.save_session(session) + self.storage.append_event( + session_id, + {"ts": datetime.now().isoformat(), "type": "replan", "reason": reason, "diff": diff}, + ) + return new_plan + + async def generate_practice(self, session_id: str, task_id: str, count: int = 3) -> dict: + plan = self.storage.load_plan(session_id) + task = next((item for item in plan.tasks if item.task_id == task_id), None) + if task is None: + raise ValueError(f"Task not found: {task_id}") + if task.practice_spec is not None: + task.practice_spec.count = count + practice_dir = self.storage.get_session_dir(session_id) / "practice" + return await self.exercise_adapter.generate_for_task(task, plan.kb_name, practice_dir) + + async def analyze_exam_materials( + self, + session_id: str, + *, + pasted_text: str = "", + uploads: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + session = self.storage.load_session(session_id) + uploads = uploads or [] + file_texts: list[dict[str, str]] = [] + image_files: list[dict[str, Any]] = [] + + for item in uploads: + name = str(item.get("name", "upload")).strip() or "upload" + content_type = str(item.get("content_type", "") or "").lower() + data = item.get("data") + if not isinstance(data, (bytes, bytearray)): + continue + payload = bytes(data) + if content_type.startswith("image/"): + image_files.append({"name": name, "content_type": content_type, "data": payload}) + continue + extracted = self._extract_text_from_upload(name, content_type, payload) + if extracted: + file_texts.append({"source": name, "text": extracted}) + + analysis = await self.exam_miner.analyze( + pasted_text=pasted_text, + file_texts=file_texts, + image_files=image_files, + language=parse_language(session.goal_config.preferences.language), + ) + + session_dir = self.storage.get_session_dir(session_id) + artifact_dir = session_dir / "artifacts" + artifact_dir.mkdir(parents=True, exist_ok=True) + artifact_path = artifact_dir / "exam_analysis_latest.json" + artifact_path.write_text(json.dumps(analysis, ensure_ascii=False, indent=2), encoding="utf-8") + + return { + "session_id": session_id, + "analysis": analysis, + "artifact_path": str(artifact_path), + } + + async def generate_day_interactive_page( + self, + session_id: str, + day_index: int, + force: bool = False, + ) -> dict: + detail = self.get_day_plan_detail(session_id, day_index) + plan = self.storage.load_plan(session_id) + session = self.storage.load_session(session_id) + session_dir = self.storage.get_session_dir(session_id) + target_path = session_dir / "artifacts" / f"day_{day_index:02d}_interactive.html" + + if target_path.exists() and not force: + return { + "session_id": session_id, + "day_index": day_index, + "path": str(target_path), + "cached": True, + "html": target_path.read_text(encoding="utf-8"), + } + + topics = [ + task.title.replace("Learn ", "").replace("Practice ", "").replace("Review ", "").replace("学习 ", "").replace("练习 ", "").replace("复习 ", "") + for task in plan.tasks + if task.day_index == day_index + ][:4] + title = f"Day {day_index} Interactive Lesson: {', '.join(topics) if topics else 'Review and Reinforcement'}" + language = parse_language(session.goal_config.preferences.language) + blueprint = await self._generate_interactive_blueprint(detail, session.goal_config, language) + knowledge = { + "knowledge_title": title, + "knowledge_summary": self._build_interactive_summary(detail, session.goal_config, blueprint), + "user_difficulty": "; ".join(detail.pitfalls[:3]) if detail.pitfalls else "Learning pacing and transfer can become imbalanced.", + "generation_requirements": self._build_interactive_generation_requirements(detail, session.goal_config), + "content_blueprint": self._format_interactive_blueprint(blueprint), + } + + llm_config = get_llm_config() + interactive_agent = InteractiveAgent( + api_key=llm_config.api_key, + base_url=llm_config.effective_url or llm_config.base_url or "", + api_version=getattr(llm_config, "api_version", None), + language=language, + binding=getattr(llm_config, "binding", "openai"), + ) + + result = await interactive_agent.process(knowledge=knowledge) + html = str(result.get("html") or "").strip() + if not html: + raise ValueError(result.get("error", "Failed to generate interactive page")) + + target_path.write_text(html, encoding="utf-8") + return { + "session_id": session_id, + "day_index": day_index, + "path": str(target_path), + "cached": False, + "html": html, + } + + async def answer_day_interactive_question( + self, + session_id: str, + day_index: int, + question: str, + ) -> dict: + if not question.strip(): + raise ValueError("Question cannot be empty") + + detail = self.get_day_plan_detail(session_id, day_index) + session = self.storage.load_session(session_id) + session_dir = self.storage.get_session_dir(session_id) + lesson_path = session_dir / "artifacts" / f"day_{day_index:02d}_interactive.html" + lesson_text = "" + if lesson_path.exists(): + lesson_text = self._extract_text_from_html(lesson_path.read_text(encoding="utf-8"))[:6000] + + context = self._build_interactive_summary(detail, session.goal_config) + system_prompt = ( + "You are an exam-focused learning tutor. " + "Prioritize knowledge structure, high-yield points, common mistakes, and fast decision methods. " + "Keep answers concise, professional, and actionable." + ) + user_prompt = ( + f"[Today's lesson knowledge structure]\n{context}\n\n" + f"[Lesson excerpt]\n{lesson_text or 'No lesson excerpt available.'}\n\n" + f"[User question]\n{question.strip()}\n\n" + "Answer in English and end with 1-2 immediately actionable practice suggestions." + ) + answer = await llm_complete( + prompt=user_prompt, + system_prompt=system_prompt, + temperature=0.3, + max_tokens=900, + ) + return { + "session_id": session_id, + "day_index": day_index, + "question": question.strip(), + "answer": answer.strip(), + } + + def get_day_plan_detail(self, session_id: str, day_index: int) -> DayPlanDetail: + if day_index <= 0: + raise ValueError("day_index must be > 0") + + session = self.storage.load_session(session_id) + plan = self.storage.load_plan(session_id) + day = next((item for item in plan.days if item.day_index == day_index), None) + tasks = sorted( + (task for task in plan.tasks if task.day_index == day_index), + key=lambda item: (item.priority, item.task_id), + ) + + if day is None and not tasks: + raise ValueError(f"Day not found: {day_index}") + + if not tasks: + return DayPlanDetail( + session_id=session_id, + day_index=day_index, + date=day.date if day else None, + objective_summary="No fixed tasks today. Do a light review and warm-up.", + time_blocks=[ + DayPlanTimeBlock( + block_id=f"day{day_index}_fallback_01", + title="Light Review and Warm-up", + kind=TaskKind.REVIEW, + minutes=30, + steps=[ + "Review yesterday's mistakes and key notes (10 min)", + "Explain core concepts out loud and write a 3-item checklist (10 min)", + "Preview tomorrow's topics and list 2 questions (10 min)", + ], + linked_task_ids=[], + ) + ], + key_points=["Review prior knowledge", "Warm up new knowledge", "Keep learning continuity"], + pitfalls=["Reviewing without practice accelerates forgetting", "Review without conclusions limits progress"], + acceptance_criteria=["Complete one 3-line review note", "List at least 2 unresolved questions"], + review_actions=["Save your review notes", "Prepare materials and question sets for tomorrow"], + linked_task_ids=[], + ) + + linked_task_ids = [task.task_id for task in tasks] + objective_summary = self._build_objective_summary(tasks) + time_blocks = [self._to_time_block(day_index, idx, task) for idx, task in enumerate(tasks, start=1)] + key_points = self._extract_key_points(tasks) + pitfalls = self._collect_pitfalls(tasks) + acceptance_criteria = self._build_acceptance_criteria(tasks) + review_actions = self._build_review_actions(tasks, session.goal_config.preferences.include_practice) + + return DayPlanDetail( + session_id=session_id, + day_index=day_index, + date=day.date if day else None, + objective_summary=objective_summary, + time_blocks=time_blocks, + key_points=key_points, + pitfalls=pitfalls, + acceptance_criteria=acceptance_criteria, + review_actions=review_actions, + linked_task_ids=linked_task_ids, + ) + + def _build_days(self, daily_minutes: int, remaining_days: int, tasks: list) -> list[PlanDay]: + dates = future_dates(remaining_days) + days: list[PlanDay] = [] + for day_index in range(1, remaining_days + 1): + task_ids = [task.task_id for task in tasks if task.day_index == day_index] + days.append( + PlanDay( + day_index=day_index, + date=dates[day_index - 1], + budget_minutes=daily_minutes, + task_ids=task_ids, + notes="Auto-generated learning tasks", + ) + ) + return days + + def _stage_event(self, stage: str, progress: float) -> dict: + return { + "ts": datetime.now().isoformat(), + "type": "stage", + "stage": stage, + "progress": progress, + } + + def _to_time_block(self, day_index: int, index: int, task: Task) -> DayPlanTimeBlock: + minutes = max(15, min(task.estimate_minutes, 120)) + return DayPlanTimeBlock( + block_id=f"day{day_index}_{index:02d}", + title=task.title, + kind=task.kind, + minutes=minutes, + steps=self._build_steps_for_task(task), + linked_task_ids=[task.task_id], + ) + + def _build_objective_summary(self, tasks: list[Task]) -> str: + themes = [ + task.title.replace("Learn ", "").replace("Practice ", "").replace("学习 ", "").replace("练习 ", "") + for task in tasks[:3] + ] + exam_oriented = any( + task.practice_spec and task.practice_spec.question_type == "mimic_exam" + for task in tasks + ) + if exam_oriented: + return ( + f"Complete {len(tasks)} tasks focused on high-yield score improvement: {', '.join(themes)}. " + "Follow an explain-practice-correct-repractice loop and prioritize high-return content." + ) + return f"Complete {len(tasks)} tasks with focus on: {', '.join(themes)}." + + def _extract_key_points(self, tasks: list[Task]) -> list[str]: + points: list[str] = [] + for task in tasks: + for chunk in re.split(r"[,,。;;、\n]+", task.objective): + value = chunk.strip() + if 4 <= len(value) <= 30 and value not in points: + points.append(value) + if len(points) >= 6: + return points + for task in tasks: + title = ( + task.title.replace("Learn ", "") + .replace("Practice ", "") + .replace("Review ", "") + .replace("学习 ", "") + .replace("练习 ", "") + .replace("复习 ", "") + .strip() + ) + if title and title not in points: + points.append(title) + if len(points) >= 6: + break + return points[:6] + + def _collect_pitfalls(self, tasks: list[Task]) -> list[str]: + pitfalls: list[str] = [] + for task in tasks: + template = { + TaskKind.LEARN: [ + "Reading definitions without active recall creates false mastery", + "Ignoring prerequisite concepts causes downstream gaps", + ], + TaskKind.PRACTICE: [ + "Doing questions without summarizing patterns slows transfer", + "Not tracing root causes of mistakes leads to repeat errors", + ], + TaskKind.REVIEW: [ + "Review by rereading only produces weak retention", + ], + TaskKind.QUIZ: [ + "Looking only at scores without error causes blocks optimization", + ], + }.get(task.kind, ["Imbalanced pacing reduces daily completion quality"]) + for item in template: + if item not in pitfalls: + pitfalls.append(item) + return pitfalls[:5] + + def _build_acceptance_criteria(self, tasks: list[Task]) -> list[str]: + exam_oriented = any( + task.practice_spec and task.practice_spec.question_type == "mimic_exam" + for task in tasks + ) + criteria = [ + f"Complete {len(tasks)} tasks today and record completion status", + "Produce at least 3 key takeaways (concepts/methods/common mistakes)", + ] + if any(task.kind == TaskKind.PRACTICE for task in tasks): + criteria.append("After practice, document at least 2 error causes and fixes") + if exam_oriented: + criteria.append("Reach at least 70% accuracy on mock/high-yield sets (otherwise do one extra round)") + criteria.append("Use 3 minutes to verbally summarize today's core content") + return criteria + + def _build_review_actions(self, tasks: list[Task], include_practice: bool) -> list[str]: + exam_oriented = any( + task.practice_spec and task.practice_spec.question_type == "mimic_exam" + for task in tasks + ) + actions = [ + "Compress today's notes into a 3-line summary and save it", + "Mark the least stable knowledge point for priority review tomorrow", + ] + if include_practice and any(task.practice_spec for task in tasks): + actions.append("Archive mistakes as concept misunderstanding / calculation error / misreading") + if exam_oriented: + actions.append("Put high-loss question patterns into tomorrow's first timed practice block") + actions.append("Preview tomorrow's tasks and prepare required materials") + return actions + + def _build_steps_for_task(self, task: Task) -> list[str]: + topic = ( + task.title.replace("Learn ", "") + .replace("Practice ", "") + .replace("Review ", "") + .replace("学习 ", "") + .replace("练习 ", "") + .replace("复习 ", "") + ) + if task.kind == TaskKind.LEARN: + exam_oriented = bool(task.practice_spec and task.practice_spec.question_type == "mimic_exam") + base_steps = [ + f"Quickly review core definitions and conclusions of {topic}", + "Restate key ideas in your own words and write key formulas/points", + "Finish 1-2 basic questions to verify understanding", + ] + if exam_oriented: + base_steps.append("Extract a high-yield checklist and mark 2 mistake triggers") + return base_steps + if task.kind == TaskKind.PRACTICE: + count = task.practice_spec.count if task.practice_spec else 3 + question_type = task.practice_spec.question_type if task.practice_spec else "short_answer" + first_step = f"Complete {count} targeted questions by pattern" + if question_type == "mimic_exam": + first_step = f"Complete {count} high-yield mock-style questions with per-question time control" + return [ + first_step, + "Check answers question by question and log error causes", + "Convert fragile steps into a checklist", + ] + if task.kind == TaskKind.REVIEW: + return [ + f"Review weak points in {topic}", + "Complete one retraining set for mistakes", + "Write review conclusions and update your error bank", + ] + return [ + f"Complete tasks related to {topic}", + "Record outcomes and update learning status", + ] + + def _build_interactive_summary( + self, + detail: DayPlanDetail, + goal_config: GoalConfig, + blueprint: dict[str, Any] | None = None, + ) -> str: + topics = [] + for block in detail.time_blocks: + topic = ( + block.title.replace("Learn ", "") + .replace("Practice ", "") + .replace("Review ", "") + .replace("Error Drill ", "") + .replace("学习 ", "") + .replace("练习 ", "") + .replace("复习 ", "") + .replace("错题再练 ", "") + .strip() + ) + if topic and topic not in topics: + topics.append(topic) + topic_text = ", ".join(topics[:6]) if topics else "No clear topic yet" + + domain_pack = self._build_topic_domain_pack(topics) + sections = [ + f"Today's core topics: {topic_text}", + "Output must be exam-ready lecture-style content and avoid vague statements.", + "Knowledge flow: definition and essence -> high-yield points -> common mistakes -> question templates and solving flow.", + self._build_goal_config_profile(goal_config), + ] + if detail.key_points: + sections.append("High-yield points: " + ", ".join(detail.key_points[:8])) + if detail.pitfalls: + sections.append("Common mistakes: " + "; ".join(detail.pitfalls[:5])) + if detail.acceptance_criteria: + sections.append("Success criteria: " + "; ".join(detail.acceptance_criteria[:4])) + sections.append("Suggested teaching sequence: concept definition -> high-yield patterns -> solution templates -> mistake correction.") + if domain_pack: + sections.append("[Domain-enhanced lecture template]\n" + domain_pack) + if blueprint: + sections.append("[Structured lesson blueprint]\n" + self._format_interactive_blueprint(blueprint)) + return "\n".join(sections) + + async def _generate_interactive_blueprint( + self, + detail: DayPlanDetail, + goal_config: GoalConfig, + language: str, + ) -> dict[str, Any]: + fallback = self._fallback_interactive_blueprint(detail) + topics = [] + for block in detail.time_blocks: + topic = ( + block.title.replace("Learn ", "") + .replace("Practice ", "") + .replace("Review ", "") + .replace("Error Drill ", "") + .replace("学习 ", "") + .replace("练习 ", "") + .replace("复习 ", "") + .replace("错题再练 ", "") + .strip() + ) + if topic and topic not in topics: + topics.append(topic) + topic_text = ", ".join(topics[:6]) if topics else "Today's core topics" + key_points = ", ".join(detail.key_points[:8]) if detail.key_points else "No explicit high-yield points yet" + pitfalls = "; ".join(detail.pitfalls[:6]) if detail.pitfalls else "No explicit common mistakes yet" + objective = detail.objective_summary or f"Build exam-reusable capability around {topic_text}" + profile_text = self._build_goal_config_profile(goal_config) + system_prompt = ( + "You are an exam-oriented curriculum designer." + " Return a concrete, actionable JSON blueprint with knowledge map, high-yield points, mistakes, question templates, and method flow." + " Output JSON object only." + ) + user_prompt = ( + f"[Objective]\n{objective}\n\n" + f"[Topics]\n{topic_text}\n\n" + f"[Plan profile]\n{profile_text}\n\n" + f"[Known key points]\n{key_points}\n\n" + f"[Known pitfalls]\n{pitfalls}\n\n" + "Return JSON object with keys: domain, objective, knowledge_map, high_frequency_points, common_mistakes, question_patterns, method_flow, rapid_review, practice_checklist." + ) + + try: + raw = await llm_complete( + prompt=user_prompt, + system_prompt=system_prompt, + temperature=0.2, + max_tokens=1400, + response_format={"type": "json_object"}, + ) + parsed = self._parse_json_payload(raw) + if isinstance(parsed, dict): + normalized = self._normalize_interactive_blueprint(parsed, fallback) + score, issues = self._score_interactive_blueprint(normalized) + if score >= 72: + return normalized + repaired = await self._repair_interactive_blueprint( + normalized, + issues, + detail, + goal_config, + language, + ) + repaired_score, _ = self._score_interactive_blueprint(repaired) + return repaired if repaired_score >= score else normalized + return fallback + except Exception: + return fallback + + async def _repair_interactive_blueprint( + self, + blueprint: dict[str, Any], + issues: list[str], + detail: DayPlanDetail, + goal_config: GoalConfig, + language: str, + ) -> dict[str, Any]: + fallback = self._fallback_interactive_blueprint(detail) + profile_text = self._build_goal_config_profile(goal_config) + issue_text = "; ".join(issues[:6]) if issues else "Insufficient detail density" + system_prompt = ( + "You are a curriculum QA and refinement expert." + " Improve the blueprint with concrete and actionable details. Output JSON object only." + ) + user_prompt = ( + f"[Issues]\n{issue_text}\n\n" + f"[Plan profile]\n{profile_text}\n\n" + f"[Current Blueprint]\n{json.dumps(blueprint, ensure_ascii=False)}\n\n" + "Return improved JSON with the same keys and stronger specificity." + ) + try: + raw = await llm_complete( + prompt=user_prompt, + system_prompt=system_prompt, + temperature=0.2, + max_tokens=1400, + response_format={"type": "json_object"}, + ) + parsed = self._parse_json_payload(raw) + if isinstance(parsed, dict): + return self._normalize_interactive_blueprint(parsed, fallback) + return blueprint + except Exception: + return blueprint + + def _normalize_interactive_blueprint( + self, + blueprint: dict[str, Any], + fallback: dict[str, Any], + ) -> dict[str, Any]: + normalized = dict(fallback) + for key in ( + "domain", + "objective", + "knowledge_map", + "high_frequency_points", + "common_mistakes", + "question_patterns", + "method_flow", + "rapid_review", + "practice_checklist", + ): + value = blueprint.get(key) + if value: + normalized[key] = value + return normalized + + def _parse_json_payload(self, response: str) -> object: + text = (response or "").strip() + if not text: + raise json.JSONDecodeError("Empty response", response, 0) + try: + return json.loads(text) + except json.JSONDecodeError: + pass + fence_match = re.search(r"```json\s*([\s\S]*?)\s*```", text, re.IGNORECASE) + if fence_match: + return json.loads(fence_match.group(1).strip()) + obj_start = text.find("{") + obj_end = text.rfind("}") + if obj_start != -1 and obj_end != -1 and obj_end > obj_start: + return json.loads(text[obj_start : obj_end + 1]) + raise json.JSONDecodeError("No JSON payload found", response, 0) + + def _fallback_interactive_blueprint(self, detail: DayPlanDetail) -> dict[str, Any]: + topics = [] + for block in detail.time_blocks: + topic = ( + block.title.replace("Learn ", "") + .replace("Practice ", "") + .replace("Review ", "") + .replace("Error Drill ", "") + .replace("学习 ", "") + .replace("练习 ", "") + .replace("复习 ", "") + .replace("错题再练 ", "") + .strip() + ) + if topic and topic not in topics: + topics.append(topic) + if not topics: + topics = detail.key_points[:4] if detail.key_points else ["Core concepts", "High-yield points", "Typical patterns"] + knowledge_map = [ + { + "module": f"{index}. {topic}", + "focus_points": [ + f"Core definitions and boundaries of {topic}", + f"Common exam usage and solving steps for {topic}", + ], + } + for index, topic in enumerate(topics[:4], start=1) + ] + high_frequency_points = [ + { + "point": item, + "why_high_yield": "Appears frequently and transfers to later question patterns", + "exam_usage": "Often appears in foundational judgment or integrated application forms", + } + for item in (detail.key_points[:6] or topics[:4]) + ] + common_mistakes = [ + { + "mistake": pitfall, + "fix": "Clarify definitions and conditions first, then solve step by step and verify key links", + "quick_check": "Check whether conditions are complete and conclusions match the question", + } + for pitfall in (detail.pitfalls[:5] or ["Incomplete concept understanding leads to method misuse"]) + ] + question_patterns = [ + { + "pattern": "Foundational judgment questions", + "trigger": "The prompt asks for correctness, feasibility, or conceptual relation judgment", + "steps": ["Extract conditions", "Apply definitions/rules", "Give conclusion with brief reasoning"], + }, + { + "pattern": "Integrated application questions", + "trigger": "Requires joint solving across multiple knowledge points", + "steps": ["Break into subproblems", "Choose high-yield method", "Solve stepwise and back-check"], + }, + { + "pattern": "Parameter/boundary questions", + "trigger": "Requires parameter range or boundary condition solving", + "steps": ["List conditions", "Combine constraints", "Solve parameters and check edge cases"], + }, + ] + return { + "domain": "exam-oriented", + "objective": detail.objective_summary, + "knowledge_map": knowledge_map, + "high_frequency_points": high_frequency_points, + "common_mistakes": common_mistakes, + "question_patterns": question_patterns, + "method_flow": ["Identify question type and objective", "Match method and execute", "Review result and log error causes"], + "rapid_review": detail.acceptance_criteria[:3] + or ["Restate core concepts", "List common pitfalls", "Write a reusable solving flow"], + "practice_checklist": detail.review_actions[:4] + or ["Finish one foundational and one integrated question", "Classify mistakes by cause", "Record priority review points for tomorrow"], + } + + def _format_interactive_blueprint(self, blueprint: dict[str, Any]) -> str: + sections = [ + f"Domain: {blueprint.get('domain', 'unknown')}", + f"Objective: {blueprint.get('objective', '')}", + ] + knowledge_map = blueprint.get("knowledge_map") or [] + if isinstance(knowledge_map, list) and knowledge_map: + lines = [] + for idx, item in enumerate(knowledge_map[:6], start=1): + if not isinstance(item, dict): + continue + module = str(item.get("module", f"Module {idx}")).strip() + focus_points = item.get("focus_points") or [] + if isinstance(focus_points, list): + focus_text = ", ".join(str(p).strip() for p in focus_points[:5] if str(p).strip()) + else: + focus_text = str(focus_points).strip() + lines.append(f"- {module}: {focus_text}") + if lines: + sections.append("Knowledge Map:\n" + "\n".join(lines)) + + def _items(name: str, key: str, fields: list[str], limit: int = 8): + values = blueprint.get(key) or [] + if not isinstance(values, list) or not values: + return + rendered = [] + for item in values[:limit]: + if isinstance(item, dict): + parts = [str(item.get(f, "")).strip() for f in fields if str(item.get(f, "")).strip()] + if parts: + rendered.append("- " + " | ".join(parts)) + else: + text = str(item).strip() + if text: + rendered.append(f"- {text}") + if rendered: + sections.append(f"{name}:\n" + "\n".join(rendered)) + + _items("High-yield Points", "high_frequency_points", ["point", "why_high_yield", "exam_usage"]) + _items("Common Mistakes", "common_mistakes", ["mistake", "fix", "quick_check"]) + _items("Question Templates", "question_patterns", ["pattern", "trigger", "steps"]) + _items("Method Flow", "method_flow", []) + _items("30-Second Review", "rapid_review", []) + _items("Practice Checklist", "practice_checklist", []) + return "\n\n".join(sections) + + def _score_interactive_blueprint(self, blueprint: dict[str, Any]) -> tuple[int, list[str]]: + score = 0 + issues: list[str] = [] + if str(blueprint.get("domain", "")).strip(): + score += 8 + else: + issues.append("Missing domain label") + if len(str(blueprint.get("objective", "")).strip()) >= 8: + score += 8 + else: + issues.append("Objective is too short") + + knowledge_map = blueprint.get("knowledge_map") + if isinstance(knowledge_map, list) and len(knowledge_map) >= 2: + detailed_modules = 0 + for item in knowledge_map: + if not isinstance(item, dict): + continue + fp = item.get("focus_points") + if isinstance(fp, list) and len([x for x in fp if str(x).strip()]) >= 2: + detailed_modules += 1 + if detailed_modules >= 2: + score += 22 + else: + score += 10 + issues.append("Knowledge map hierarchy is unclear") + else: + issues.append("Insufficient knowledge map") + + def _count_rich(items: Any, required_fields: list[str]) -> int: + if not isinstance(items, list): + return 0 + cnt = 0 + for item in items: + if isinstance(item, dict): + if all(str(item.get(field, "")).strip() for field in required_fields): + cnt += 1 + return cnt + + hf_cnt = _count_rich( + blueprint.get("high_frequency_points"), + ["point", "why_high_yield", "exam_usage"], + ) + if hf_cnt >= 3: + score += 18 + elif hf_cnt >= 2: + score += 10 + issues.append("High-yield point details are thin") + else: + issues.append("Insufficient high-yield points") + + mistake_cnt = _count_rich( + blueprint.get("common_mistakes"), + ["mistake", "fix", "quick_check"], + ) + if mistake_cnt >= 3: + score += 16 + elif mistake_cnt >= 2: + score += 9 + issues.append("Mistake correction details are insufficient") + else: + issues.append("Insufficient common mistakes") + + patterns = blueprint.get("question_patterns") + if isinstance(patterns, list) and patterns: + rich_patterns = 0 + for item in patterns: + if not isinstance(item, dict): + continue + steps = item.get("steps") + if ( + str(item.get("pattern", "")).strip() + and str(item.get("trigger", "")).strip() + and isinstance(steps, list) + and len([x for x in steps if str(x).strip()]) >= 2 + ): + rich_patterns += 1 + if rich_patterns >= 2: + score += 16 + else: + score += 8 + issues.append("Question template steps are incomplete") + else: + issues.append("Missing question templates") + + method_flow = blueprint.get("method_flow") + if isinstance(method_flow, list) and len([x for x in method_flow if str(x).strip()]) >= 3: + score += 12 + else: + issues.append("Method flow is not actionable") + + return min(score, 100), issues + + def _build_interactive_generation_requirements(self, detail: DayPlanDetail, goal_config: GoalConfig) -> str: + topics = [] + for block in detail.time_blocks: + topic = ( + block.title.replace("Learn ", "") + .replace("Practice ", "") + .replace("Review ", "") + .replace("Error Drill ", "") + .replace("学习 ", "") + .replace("练习 ", "") + .replace("复习 ", "") + .replace("错题再练 ", "") + .strip() + ) + if topic and topic not in topics: + topics.append(topic) + topic_text = ", ".join(topics[:6]) if topics else "Today's core topics" + strategy_label = { + "high_yield_first": "high-yield first", + "depth_first": "depth first", + "breadth_first": "breadth first", + }.get(goal_config.preferences.strategy.value, "high-yield first") + level_label = { + "foundation": "foundation", + "competent": "competent", + "advanced": "advanced", + }.get(goal_config.goal_level.value, "competent") + weekly_rhythm = ( + f"Study {goal_config.days_per_week} days per week (other days are for light review/rest)." + if goal_config.days_per_week < 7 + else "Study 7 days per week." + ) + return ( + f"Target topics: {topic_text}\n" + f"Goal level: {level_label}; strategy: {strategy_label}; " + f"time budget: {goal_config.remaining_days} days x {goal_config.daily_minutes} minutes; " + f"practice ratio: {int(goal_config.preferences.practice_ratio*100)}%; review ratio: {int(goal_config.preferences.review_ratio*100)}%.\n" + f"Rhythm: {weekly_rhythm}\n" + "1) Must include an overall knowledge-map section with topic -> subtopic -> key-point hierarchy.\n" + "2) Must include high-yield points, each with conclusion/formula + condition + reminder.\n" + "3) Must include common mistakes in mistake -> correction -> quick check format.\n" + "4) Must include core question templates with trigger -> steps -> warning.\n" + "5) Must include a visual method-selection flow (cards or flowchart), not plain paragraph only.\n" + "6) Math/engineering topics should include formulas; programming topics should include code snippets.\n" + "7) Wording must be specific and avoid placeholders.\n" + "8) Add a 30-second review card at the bottom of each major section.\n" + "9) Page default language must be English, including buttons and helper text.\n" + "10) Interaction should include expand/collapse, mistake-correction toggles, and template step switching." + ) + + def _build_goal_config_profile(self, goal_config: GoalConfig) -> str: + level_line = { + "foundation": "Goal level: foundation (stabilize definitions, basic patterns, and high-yield basics first).", + "competent": "Goal level: competent (balance core principles and medium-difficulty transfer).", + "advanced": "Goal level: advanced (strengthen integrated problems, boundary cases, and harder variations).", + }.get(goal_config.goal_level.value, "Goal level: competent.") + strategy_line = { + "high_yield_first": "Strategy: high-yield first (cover high-frequency, high-score topics first).", + "depth_first": "Strategy: depth first (go deep along principle -> variation -> mistakes -> retraining).", + "breadth_first": "Strategy: breadth first (build full map first, then connect key relations).", + }.get(goal_config.preferences.strategy.value, "Strategy: high-yield first.") + rhythm_line = ( + f"Rhythm: {goal_config.remaining_days} days, {goal_config.daily_minutes} minutes/day, {goal_config.days_per_week} study days/week." + ) + practice_line = ( + f"Practice config: include_practice={goal_config.preferences.include_practice}, " + f"practice_ratio={goal_config.preferences.practice_ratio:.2f}, " + f"review_ratio={goal_config.preferences.review_ratio:.2f}." + ) + return "\n".join([level_line, strategy_line, rhythm_line, practice_line]) + + def _extract_text_from_html(self, html: str) -> str: + text = re.sub(r"", " ", html, flags=re.IGNORECASE) + text = re.sub(r"", " ", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", " ", text) + return re.sub(r"\s+", " ", text).strip() + + def _extract_text_from_upload(self, name: str, content_type: str, payload: bytes) -> str: + lower_name = name.lower() + if content_type == "application/pdf" or lower_name.endswith(".pdf"): + return self._extract_pdf_text(payload) + if ( + content_type.startswith("text/") + or lower_name.endswith(".txt") + or lower_name.endswith(".md") + or lower_name.endswith(".json") + ): + return payload.decode("utf-8", errors="ignore") + try: + return payload.decode("utf-8", errors="ignore") + except Exception: + return "" + + def _extract_pdf_text(self, payload: bytes) -> str: + try: + import fitz # type: ignore + + with fitz.open(stream=io.BytesIO(payload), filetype="pdf") as document: + pages = [] + for page in document: + pages.append(page.get_text("text")) + return "\n".join(pages).strip() + except Exception: + return "" + + def _build_topic_domain_pack(self, topics: list[str]) -> str: + lowered = " ".join(topics).lower() + if any(token in lowered for token in ("极限", "连续", "limit", "continuity")): + return self._calculus_limit_continuity_pack() + + # Generic exam-oriented scaffolding for other topics. + return ( + "I. Overall Knowledge Map\n" + "1) Core definitions and essence\n" + "2) Key properties and common conclusions\n" + "3) Core relations (necessary/sufficient conditions)\n\n" + "II. High-yield Points (memorization-ready)\n" + "- 3-7 key formulas/conclusions\n" + "- Each point includes conditions and common variations\n\n" + "III. Common Mistakes\n" + "- Concept misclassification\n" + "- Missing conditions\n" + "- Method misuse\n\n" + "IV. Question Templates (directly usable)\n" + "- Pattern 1: foundational judgment questions\n" + "- Pattern 2: integrated application questions\n" + "- Pattern 3: parameter/proof questions" + ) + + def _calculus_limit_continuity_pack(self) -> str: + return ( + "I. Knowledge Map (must be visualized)\n" + "1. Limits\n" + "- Core definition: $\\lim_{x\\to x_0} f(x)=L$.\n" + "- Three views: numerical approach, graphical approach, and $\\varepsilon-\\delta$ definition.\n" + "- High-yield tools: algebraic transforms, squeeze theorem, monotone-bounded sequence ideas.\n" + "2. Continuity\n" + "- Definition: $\\lim_{x\\to x_0} f(x)=f(x_0)$.\n" + "- Three checks: function value exists, limit exists, and they are equal.\n" + "- Essence: no break at the point.\n" + "3. Relation between limits and continuity\n" + "- One-line rule: continuity = limit exists and equals function value.\n\n" + "II. High-yield points (memorize directly)\n" + "1) Important limits (at minimum)\n" + "- $\\lim_{x\\to 0}\\frac{\\sin x}{x}=1$\n" + "- $\\lim_{x\\to 0}(1+x)^{1/x}=e$\n" + "- $\\lim_{x\\to 0}\\frac{\\tan x}{x}=1$\n" + "- $\\lim_{x\\to 0}\\frac{e^x-1}{x}=1$\n" + "- $\\lim_{x\\to 0}\\frac{\\ln(1+x)}{x}=1$\n" + "2) Equivalent infinitesimal substitutions\n" + "- $\\sin x\\sim x$, $\\tan x\\sim x$, $1-\\cos x\\sim x^2/2$, $e^x-1\\sim x$\n" + "- Reminder: prefer multiplicative/division structures; be careful with additive/subtractive forms.\n" + "3) L'Hopital's rule\n" + "- Applicable forms: $0/0$ or $\\infty/\\infty$.\n" + "- Re-check indeterminate form after each differentiation.\n\n" + "III. Common mistakes\n" + "- Missing the left-limit = right-limit condition.\n" + "- Confusing removable/jump/infinite discontinuities.\n" + "- Misusing equivalent substitutions in additive/subtractive forms.\n" + "- Overusing L'Hopital where form is not indeterminate.\n" + "- Forgetting function-value conditions in piecewise continuity checks.\n\n" + "IV. Frequent templates\n" + "- Pattern 1: evaluate limits (substitute -> transform -> substitute equivalent forms -> L'Hopital -> squeeze)\n" + "- Pattern 2: test continuity (left limit, right limit, value, compare)\n" + "- Pattern 3: fill value for continuity (compute limit, set equal to parameter)\n" + "- Pattern 4: solve/prove with parameters (write form, set conditions, solve, verify edges)" + ) diff --git a/deeptutor/agents/goal/planner.py b/deeptutor/agents/goal/planner.py new file mode 100644 index 000000000..f4b384236 --- /dev/null +++ b/deeptutor/agents/goal/planner.py @@ -0,0 +1,388 @@ +"""Planning logic for Goal mode.""" + +from __future__ import annotations + +from collections import defaultdict, deque + +from deeptutor.agents.goal.models import GoalConfig, KnowledgeEdge, KnowledgeNode, PracticeSpec, Task, TaskKind, TaskResource + + +class Planner: + """Create day-level tasks from a weighted graph.""" + + def plan( + self, + nodes: list[KnowledgeNode], + edges: list[KnowledgeEdge], + goal_config: GoalConfig, + ) -> list[Task]: + ordered_nodes = self._topo_sort(nodes, edges) + selected_nodes = self._select_high_yield_nodes(ordered_nodes, goal_config) + tasks: list[Task] = [] + current_day = 1 + remaining_budget = goal_config.daily_minutes + target_days = min(goal_config.remaining_days, max(len(selected_nodes), 1)) + exam_oriented = self._is_exam_oriented(goal_config.goal_statement) + practice_profile = self._practice_profile(goal_config, exam_oriented) + + for index, node in enumerate(selected_nodes, start=1): + learn_minutes = self._learn_minutes(node, goal_config) + if learn_minutes > remaining_budget and current_day < goal_config.remaining_days: + current_day += 1 + remaining_budget = goal_config.daily_minutes + + topic = node.title + tasks.append( + Task( + task_id=f"task_day{current_day}_{index:03d}", + day_index=current_day, + node_id=node.node_id, + title=f"Learn {topic}", + kind=TaskKind.LEARN, + objective=self._learn_objective(topic, exam_oriented, goal_config), + estimate_minutes=learn_minutes, + priority=index, + resources=[TaskResource(type="kb", ref=f"{goal_config.kb_name}/{node.node_id}")], + practice_spec=( + PracticeSpec( + difficulty=practice_profile["difficulty"], + question_type=practice_profile["question_type"], + count=practice_profile["count"], + ) + if goal_config.preferences.include_practice + else None + ), + ) + ) + remaining_budget -= learn_minutes + + if ( + goal_config.preferences.include_practice + and remaining_budget >= practice_profile["minutes_floor"] + and current_day <= goal_config.remaining_days + ): + practice_minutes = min(practice_profile["minutes"], remaining_budget) + tasks.append( + Task( + task_id=f"task_day{current_day}_{index:03d}_practice", + day_index=current_day, + node_id=node.node_id, + title=f"Practice {topic}", + kind=TaskKind.PRACTICE, + objective=self._practice_objective(topic, exam_oriented, goal_config), + estimate_minutes=practice_minutes, + priority=index, + resources=[TaskResource(type="kb", ref=f"{goal_config.kb_name}/{node.node_id}")], + practice_spec=PracticeSpec( + difficulty=practice_profile["difficulty"], + question_type=practice_profile["question_type"], + count=practice_profile["count"], + ), + optional=False, + ) + ) + remaining_budget -= practice_minutes + + # Spread primary learning tasks across the available horizon before packing. + if index < target_days and current_day < goal_config.remaining_days: + current_day += 1 + remaining_budget = goal_config.daily_minutes + + if current_day >= goal_config.remaining_days and remaining_budget <= 0: + break + + # Ensure the full horizon has executable tasks even when KB drafts are sparse + # (e.g. placeholder/fallback KB with only a few nodes). + if tasks: + self._backfill_horizon(tasks, selected_nodes, goal_config, exam_oriented, practice_profile) + + return tasks + + def _backfill_horizon( + self, + tasks: list[Task], + ordered_nodes: list[KnowledgeNode], + goal_config: GoalConfig, + exam_oriented: bool, + practice_profile: dict[str, int | str], + ) -> None: + latest_day = max((task.day_index for task in tasks), default=0) + if latest_day >= goal_config.remaining_days: + return + + learn_by_node_id: dict[str, Task] = {} + for task in tasks: + if task.kind == TaskKind.LEARN and task.node_id not in learn_by_node_id: + learn_by_node_id[task.node_id] = task + + if not learn_by_node_id: + return + + ordered_learn_tasks = [ + learn_by_node_id[node.node_id] + for node in ordered_nodes + if node.node_id in learn_by_node_id + ] + if not ordered_learn_tasks: + ordered_learn_tasks = list(learn_by_node_id.values()) + + cursor = 0 + for day_index in range(latest_day + 1, goal_config.remaining_days + 1): + base_task = ordered_learn_tasks[cursor % len(ordered_learn_tasks)] + cursor += 1 + topic = base_task.title.replace("Learn ", "").replace("学习 ", "") + tasks.append( + Task( + task_id=f"{base_task.task_id}_review_d{day_index}", + day_index=day_index, + node_id=base_task.node_id, + title=f"Review and Reinforce {topic}", + kind=TaskKind.REVIEW, + objective=f"Review weak points in {topic}, summarize error causes, and complete targeted retraining.", + estimate_minutes=min(45, goal_config.daily_minutes), + priority=base_task.priority, + resources=base_task.resources, + practice_spec=base_task.practice_spec if goal_config.preferences.include_practice else None, + optional=False if day_index >= goal_config.remaining_days - 1 else True, + ) + ) + if goal_config.preferences.include_practice and goal_config.daily_minutes >= int(practice_profile["minutes_floor"]): + tasks.append( + Task( + task_id=f"{base_task.task_id}_drill_d{day_index}", + day_index=day_index, + node_id=base_task.node_id, + title=f"Error Drill {topic}", + kind=TaskKind.PRACTICE, + objective=self._practice_objective(topic, exam_oriented, goal_config), + estimate_minutes=min(int(practice_profile["minutes"]), max(20, goal_config.daily_minutes // 3)), + priority=base_task.priority, + resources=base_task.resources, + practice_spec=PracticeSpec( + difficulty=str(practice_profile["difficulty"]), + question_type=str(practice_profile["question_type"]), + count=int(practice_profile["count"]), + ), + optional=day_index < goal_config.remaining_days, + ) + ) + + def _topo_sort( + self, + nodes: list[KnowledgeNode], + edges: list[KnowledgeEdge], + ) -> list[KnowledgeNode]: + node_by_id = {node.node_id: node for node in nodes} + indegree = {node.node_id: 0 for node in nodes} + outgoing: dict[str, list[str]] = defaultdict(list) + + for edge in edges: + if edge.edge_type.value != "prerequisite": + continue + if edge.from_ not in indegree or edge.to not in indegree: + continue + indegree[edge.to] += 1 + outgoing[edge.from_].append(edge.to) + + queue = deque( + sorted( + (node_by_id[node_id] for node_id, degree in indegree.items() if degree == 0), + key=lambda node: node.weight, + reverse=True, + ) + ) + ordered: list[KnowledgeNode] = [] + while queue: + node = queue.popleft() + ordered.append(node) + for child_id in outgoing.get(node.node_id, []): + indegree[child_id] -= 1 + if indegree[child_id] == 0: + queue.append(node_by_id[child_id]) + + if len(ordered) != len(nodes): + return sorted(nodes, key=lambda item: item.weight, reverse=True) + return ordered + + def _select_high_yield_nodes(self, ordered_nodes: list[KnowledgeNode], goal_config: GoalConfig) -> list[KnowledgeNode]: + if not ordered_nodes: + return ordered_nodes + + total_budget = goal_config.remaining_days * goal_config.daily_minutes + avg_minutes = max(25, int(sum(max(20, n.estimated_minutes) for n in ordered_nodes) / len(ordered_nodes))) + capacity = max(1, total_budget // avg_minutes) + + # Under tight time constraints, prioritize top-weight nodes and allow strategic coverage tradeoffs. + compress_ratio = 1.0 + if goal_config.remaining_days <= 7: + compress_ratio = 0.85 + if goal_config.remaining_days <= 3: + compress_ratio = 0.7 + if total_budget < len(ordered_nodes) * 40: + compress_ratio = min(compress_ratio, 0.75) + + target_count = max(1, min(len(ordered_nodes), int(max(1, capacity) * compress_ratio))) + strategy = goal_config.preferences.strategy.value + if strategy == "breadth_first": + return self._select_breadth_first_nodes(ordered_nodes, target_count) + if strategy == "depth_first": + return self._select_depth_first_nodes(ordered_nodes, target_count) + return ordered_nodes[:target_count] + + def _select_breadth_first_nodes( + self, + ordered_nodes: list[KnowledgeNode], + target_count: int, + ) -> list[KnowledgeNode]: + buckets: dict[str, list[KnowledgeNode]] = defaultdict(list) + for node in ordered_nodes: + if node.tags: + key = str(node.tags[0]).strip() or node.node_type.value + else: + key = node.node_type.value + buckets[key].append(node) + + keys = [key for key, nodes in buckets.items() if nodes] + selected: list[KnowledgeNode] = [] + used = set() + while len(selected) < target_count and keys: + progressed = False + for key in list(keys): + nodes = buckets.get(key) or [] + while nodes and nodes[0].node_id in used: + nodes.pop(0) + if not nodes: + keys.remove(key) + continue + node = nodes.pop(0) + if node.node_id in used: + continue + selected.append(node) + used.add(node.node_id) + progressed = True + if len(selected) >= target_count: + break + if not progressed: + break + + if len(selected) < target_count: + for node in ordered_nodes: + if node.node_id in used: + continue + selected.append(node) + used.add(node.node_id) + if len(selected) >= target_count: + break + return selected + + def _select_depth_first_nodes( + self, + ordered_nodes: list[KnowledgeNode], + target_count: int, + ) -> list[KnowledgeNode]: + # Depth-first flavor: prioritize one coherent chain by repeatedly selecting + # nodes that share the strongest prerequisites with already selected nodes. + if target_count <= 1: + return ordered_nodes[:target_count] + selected = [ordered_nodes[0]] + used = {ordered_nodes[0].node_id} + while len(selected) < target_count: + best_node: KnowledgeNode | None = None + best_score = -1 + for node in ordered_nodes: + if node.node_id in used: + continue + overlap = len([pid for pid in node.prerequisites if pid in used]) + score = overlap * 10 + int(node.weight * 100) + if score > best_score: + best_score = score + best_node = node + if best_node is None: + break + selected.append(best_node) + used.add(best_node.node_id) + return selected[:target_count] + + def _is_exam_oriented(self, goal_statement: str) -> bool: + if not goal_statement: + return False + text = goal_statement.lower() + return any( + token in text + for token in ( + "exam", + "past paper", + "mock", + "sprint", + "score boost", + "pass line", + "target score", + "mock", + "past paper", + "exam", + ) + ) + + def _practice_profile(self, goal_config: GoalConfig, exam_oriented: bool) -> dict[str, int | str]: + ratio = max(0.25, min(0.7, goal_config.preferences.practice_ratio)) + minutes = max(20, min(45, int(goal_config.daily_minutes * ratio * 0.6))) + question_type = "mimic_exam" if exam_oriented else "short_answer" + difficulty = "medium" + if goal_config.goal_level.value == "foundation": + difficulty = "easy" + elif goal_config.goal_level.value == "advanced": + difficulty = "hard" + count = 4 if exam_oriented else 3 + if goal_config.remaining_days <= 5: + count += 1 + if goal_config.preferences.strategy.value == "high_yield_first": + count += 1 + question_type = "mimic_exam" if exam_oriented else "short_answer" + elif goal_config.preferences.strategy.value == "depth_first": + minutes = min(50, minutes + 5) + elif goal_config.preferences.strategy.value == "breadth_first": + count = max(3, count - 1) + return { + "minutes": minutes, + "minutes_floor": 20, + "question_type": question_type, + "difficulty": difficulty, + "count": count, + } + + def _learn_minutes(self, node: KnowledgeNode, goal_config: GoalConfig) -> int: + base = max(25, min(node.estimated_minutes, 70)) + # High-yield nodes deserve denser explanation in short horizon settings. + if goal_config.remaining_days <= 7 and ("high_yield" in node.tags or "高频" in node.tags or node.weight >= 0.65): + base = min(base + 10, goal_config.daily_minutes) + if goal_config.preferences.strategy.value == "depth_first": + base = min(base + 8, goal_config.daily_minutes) + elif goal_config.preferences.strategy.value == "breadth_first": + base = max(20, base - 5) + if goal_config.goal_level.value == "foundation": + base = min(base + 5, goal_config.daily_minutes) + elif goal_config.goal_level.value == "advanced": + base = min(base + 8, goal_config.daily_minutes) + return min(base, goal_config.daily_minutes) + + def _learn_objective(self, topic: str, exam_oriented: bool, goal_config: GoalConfig) -> str: + target_band = self._target_band_label(goal_config) + if exam_oriented: + return ( + f"Cover high-yield exam patterns and worked examples for {topic}, " + f"then produce a concept-method-mistake template to reach {target_band}." + ) + return f"Understand core concepts and methods of {topic}, build transferable solving steps, and reach {target_band}." + + def _practice_objective(self, topic: str, exam_oriented: bool, goal_config: GoalConfig) -> str: + target_band = self._target_band_label(goal_config) + if exam_oriented: + return f"Complete targeted and mock-style questions on {topic}, classify error causes, and run a fix-repractice-review loop toward {target_band}." + return f"Complete targeted practice for {topic}, verify understanding, and close weak points toward {target_band}." + + def _target_band_label(self, goal_config: GoalConfig) -> str: + if goal_config.goal_level.value == "foundation": + return "baseline proficiency" + if goal_config.goal_level.value == "competent": + return "stable proficiency" + return "high-score target" diff --git a/deeptutor/agents/goal/scheduler.py b/deeptutor/agents/goal/scheduler.py new file mode 100644 index 000000000..e5e452fb0 --- /dev/null +++ b/deeptutor/agents/goal/scheduler.py @@ -0,0 +1,129 @@ +"""Feedback-based replanning for Goal mode.""" + +from __future__ import annotations + +from copy import deepcopy + +from deeptutor.agents.goal.models import CompletionStatus, Feedback, Plan, PlanDiff, Task, TaskKind, TaskStatus + + +class Scheduler: + """Apply simple deterministic replan rules to an existing plan.""" + + def replan( + self, + plan: Plan, + feedback_batch: list[Feedback], + ) -> tuple[Plan, dict]: + new_plan = deepcopy(plan) + diff = PlanDiff() + tasks_by_id = {task.task_id: task for task in new_plan.tasks} + existing_task_ids = {task.task_id for task in new_plan.tasks} + + for feedback in feedback_batch: + task = tasks_by_id.get(feedback.task_id) + if task is None: + continue + + if feedback.quiz and feedback.quiz.score / feedback.quiz.total < 0.6: + review_task = self._build_review_task(task) + if review_task.task_id not in existing_task_ids: + new_plan.tasks.append(review_task) + existing_task_ids.add(review_task.task_id) + self._assign_to_day(new_plan, review_task) + diff.added_tasks.append(review_task.task_id) + drill_task = self._build_drill_task(task) + if drill_task.task_id not in existing_task_ids: + new_plan.tasks.append(drill_task) + existing_task_ids.add(drill_task.task_id) + self._assign_to_day(new_plan, drill_task) + diff.added_tasks.append(drill_task.task_id) + + if feedback.completion in {CompletionStatus.PARTIAL, CompletionStatus.MISSED}: + task.status = TaskStatus.PENDING + task.day_index = min(task.day_index + 1, self._max_day_index(new_plan)) + diff.moved_tasks.append(task.task_id) + dropped = self._drop_low_yield_optional_task(new_plan) + if dropped: + diff.dropped_tasks.append(dropped) + elif feedback.completion == CompletionStatus.DONE: + task.status = TaskStatus.DONE + elif feedback.completion == CompletionStatus.SKIPPED: + task.status = TaskStatus.SKIPPED + + new_plan.plan_version += 1 + new_plan.diff = diff + self._refresh_days(new_plan) + return new_plan, diff.model_dump(mode="json") + + def _build_review_task(self, task: Task) -> Task: + return Task( + task_id=f"{task.task_id}_review", + day_index=task.day_index + 1, + node_id=task.node_id, + title=f"Review {task.title}", + kind=TaskKind.REVIEW, + objective=f"Review weak points in {task.title} and practice again for reinforcement.", + estimate_minutes=max(20, min(30, task.estimate_minutes)), + priority=task.priority, + resources=task.resources, + practice_spec=task.practice_spec, + ) + + def _build_drill_task(self, task: Task) -> Task: + practice_spec = task.practice_spec + if practice_spec is not None: + practice_spec = practice_spec.model_copy(update={"count": max(3, practice_spec.count)}) + return Task( + task_id=f"{task.task_id}_drill", + day_index=task.day_index + 1, + node_id=task.node_id, + title=f"Error Drill {task.title}", + kind=TaskKind.PRACTICE, + objective=f"Re-practice {task.title} by targeting error causes until performance is stable.", + estimate_minutes=max(20, min(35, task.estimate_minutes)), + priority=task.priority, + resources=task.resources, + practice_spec=practice_spec, + optional=False, + ) + + def _assign_to_day(self, plan: Plan, task: Task) -> None: + for day in plan.days: + if day.day_index == task.day_index: + day.task_ids.append(task.task_id) + return + plan.days.append( + type(plan.days[0]).model_validate( + { + "day_index": task.day_index, + "budget_minutes": max(task.estimate_minutes, 60), + "task_ids": [task.task_id], + } + ) + ) + + def _refresh_days(self, plan: Plan) -> None: + max_day = max((task.day_index for task in plan.tasks), default=0) + day_map = {day.day_index: day for day in plan.days} + for day_index in range(1, max_day + 1): + day = day_map.get(day_index) + if day is None: + continue + day.task_ids = [task.task_id for task in plan.tasks if task.day_index == day_index] + plan.days.sort(key=lambda item: item.day_index) + + def _drop_low_yield_optional_task(self, plan: Plan) -> str | None: + candidates = sorted( + [task for task in plan.tasks if task.optional and task.kind in {TaskKind.REVIEW, TaskKind.PRACTICE}], + key=lambda task: (task.priority, -task.day_index), + reverse=True, + ) + if not candidates: + return None + dropped = candidates[0] + plan.tasks = [task for task in plan.tasks if task.task_id != dropped.task_id] + return dropped.task_id + + def _max_day_index(self, plan: Plan) -> int: + return max((day.day_index for day in plan.days), default=1) diff --git a/deeptutor/agents/goal/storage.py b/deeptutor/agents/goal/storage.py new file mode 100644 index 000000000..43f985bcb --- /dev/null +++ b/deeptutor/agents/goal/storage.py @@ -0,0 +1,99 @@ +"""Storage helpers for Goal-Oriented Adaptive Learning Mode.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import Any + +from deeptutor.agents.goal.models import GoalSession, KnowledgeEdge, KnowledgeNode, Plan, model_to_dict +from deeptutor.services.path_service import get_path_service + + +class GoalStorage: + """Persist goal sessions, plans, graphs, and event streams under ``data/user/goal``.""" + + def __init__(self, base_dir: str | Path | None = None): + if base_dir is None: + self.base_dir = get_path_service().get_goal_root_dir() + else: + self.base_dir = Path(base_dir) + self.base_dir.mkdir(parents=True, exist_ok=True) + + def get_session_dir(self, session_id: str) -> Path: + path = self.base_dir / session_id + path.mkdir(parents=True, exist_ok=True) + (path / "practice").mkdir(parents=True, exist_ok=True) + (path / "artifacts").mkdir(parents=True, exist_ok=True) + return path + + def save_session(self, session: GoalSession) -> str: + session_dir = self.get_session_dir(session.session_id) + target = session_dir / session.artifacts.session_json + self._atomic_write_json(target, model_to_dict(session)) + return str(target) + + def save_plan(self, plan: Plan) -> str: + session_dir = self.get_session_dir(plan.session_id) + target = session_dir / plan.artifacts.plan_json + if target.exists(): + previous = self._read_json(target) + previous_version = previous.get("plan_version") + if isinstance(previous_version, int): + versioned = session_dir / f"plan.v{previous_version}.json" + self._atomic_write_json(versioned, previous) + self._atomic_write_json(target, model_to_dict(plan)) + return str(target) + + def save_graph( + self, + session_id: str, + nodes: list[KnowledgeNode], + edges: list[KnowledgeEdge], + filename: str = "graph.json", + ) -> str: + session_dir = self.get_session_dir(session_id) + target = session_dir / filename + payload = { + "knowledge_nodes": [model_to_dict(node) for node in nodes], + "knowledge_edges": [model_to_dict(edge) for edge in edges], + } + self._atomic_write_json(target, payload) + return str(target) + + def append_event(self, session_id: str, event: dict[str, Any]) -> None: + session_dir = self.get_session_dir(session_id) + target = session_dir / "events.jsonl" + with open(target, "a", encoding="utf-8") as handle: + handle.write(json.dumps(event, ensure_ascii=False) + "\n") + + def load_session(self, session_id: str) -> GoalSession: + session_dir = self.get_session_dir(session_id) + data = self._read_json(session_dir / "session.json") + return GoalSession.model_validate(data) + + def load_plan(self, session_id: str) -> Plan: + session_dir = self.get_session_dir(session_id) + data = self._read_json(session_dir / "plan.json") + return Plan.model_validate(data) + + def _read_json(self, path: Path) -> dict[str, Any]: + with open(path, encoding="utf-8") as handle: + return json.load(handle) + + def _atomic_write_json(self, path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as tmp_handle: + json.dump(payload, tmp_handle, indent=2, ensure_ascii=False) + tmp_handle.flush() + os.fsync(tmp_handle.fileno()) + Path(tmp_handle.name).replace(path) diff --git a/deeptutor/agents/goal/utils.py b/deeptutor/agents/goal/utils.py new file mode 100644 index 000000000..2d2d1d560 --- /dev/null +++ b/deeptutor/agents/goal/utils.py @@ -0,0 +1,22 @@ +"""Utility helpers for Goal mode.""" + +from __future__ import annotations + +from datetime import date, timedelta +import re + + +def slugify(value: str) -> str: + cleaned = re.sub(r"[^\w\u4e00-\u9fff]+", "_", value.strip().lower()) + cleaned = re.sub(r"_+", "_", cleaned).strip("_") + return cleaned or "item" + + +def build_session_id(prefix: str = "goal", sequence: int = 1) -> str: + today = date.today().strftime("%Y%m%d") + return f"{prefix}_{today}_{sequence:03d}" + + +def future_dates(days: int, start: date | None = None) -> list[str]: + origin = start or date.today() + return [(origin + timedelta(days=index)).isoformat() for index in range(days)] diff --git a/deeptutor/agents/goal/weight_model.py b/deeptutor/agents/goal/weight_model.py new file mode 100644 index 000000000..9dd223aac --- /dev/null +++ b/deeptutor/agents/goal/weight_model.py @@ -0,0 +1,47 @@ +"""Weight scoring for Goal mode.""" + +from __future__ import annotations + +from deeptutor.agents.goal.models import GoalConfig, GoalLevel, KnowledgeEdge, KnowledgeNode, PlanningStrategy + + +class WeightModel: + """Score knowledge nodes using deterministic signals.""" + + def score( + self, + nodes: list[KnowledgeNode], + edges: list[KnowledgeEdge], + goal_config: GoalConfig, + feedback_summary: dict | None = None, + ) -> list[KnowledgeNode]: + weakness_signals = (feedback_summary or {}).get("weakness_score", {}) + for node in nodes: + doc_freq = node.signals.get("doc_freq", 0.0) + practice_freq = node.signals.get("practice_freq", 0.0) + heading_score = node.signals.get("heading_score", 0.0) + weakness_score = weakness_signals.get(node.node_id, node.signals.get("weakness_score", 0.0)) + + weight = ( + 0.35 * doc_freq + + 0.25 * practice_freq + + 0.20 * heading_score + + 0.20 * weakness_score + ) + if goal_config.goal_level == GoalLevel.FOUNDATION and "基础" in node.tags: + weight += 0.15 + elif goal_config.goal_level == GoalLevel.ADVANCED and "基础" not in node.tags: + weight += 0.10 + + # High-yield tags and exam-style resources should be prioritized in time-constrained mode. + if any(tag in node.tags for tag in ("高频", "真题", "重点")): + weight += 0.12 + if goal_config.remaining_days <= 10: + weight += 0.08 * practice_freq + if goal_config.preferences.strategy == PlanningStrategy.HIGH_YIELD_FIRST: + weight += 0.1 * max(doc_freq, practice_freq) + elif goal_config.preferences.strategy == PlanningStrategy.DEPTH_FIRST and "基础" in node.tags: + weight += 0.05 + + node.weight = round(weight, 4) + return sorted(nodes, key=lambda item: item.weight, reverse=True) diff --git a/deeptutor/agents/guide/agents/design_agent.py b/deeptutor/agents/guide/agents/design_agent.py index 5945a501c..4f99a6c77 100644 --- a/deeptutor/agents/guide/agents/design_agent.py +++ b/deeptutor/agents/guide/agents/design_agent.py @@ -5,10 +5,10 @@ """ import json +import re from typing import Optional from deeptutor.agents.base_agent import BaseAgent -from deeptutor.utils.json_parser import parse_json_response class DesignAgent(BaseAgent): @@ -74,7 +74,7 @@ async def process(self, user_input: str) -> dict[str, object]: response = "".join(_chunks) try: - result = parse_json_response(response, logger_instance=self.logger) + result = self._parse_json_payload(response) if isinstance(result, list): knowledge_points = result @@ -108,12 +108,80 @@ async def process(self, user_input: str) -> dict[str, object]: } except json.JSONDecodeError as e: + fallback_points = self._fallback_points(user_input) return { - "success": False, - "error": f"JSON parsing failed: {e!s}", - "raw_response": response, - "knowledge_points": [], + "success": True, + "knowledge_points": fallback_points, + "total_points": len(fallback_points), + "warning": f"JSON parsing failed: {e!s}", } except Exception as e: - return {"success": False, "error": str(e), "knowledge_points": []} + fallback_points = self._fallback_points(user_input) + return { + "success": True, + "knowledge_points": fallback_points, + "total_points": len(fallback_points), + "warning": str(e), + } + + def _parse_json_payload(self, response: str) -> object: + text = (response or "").strip() + if not text: + raise json.JSONDecodeError("Empty response", response, 0) + + # 1) Direct parse + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # 2) Fenced JSON block + fence_match = re.search(r"```json\s*([\s\S]*?)\s*```", text, re.IGNORECASE) + if fence_match: + return json.loads(fence_match.group(1).strip()) + + # 3) First JSON object + obj_start = text.find("{") + obj_end = text.rfind("}") + if obj_start != -1 and obj_end != -1 and obj_end > obj_start: + candidate = text[obj_start : obj_end + 1] + try: + return json.loads(candidate) + except json.JSONDecodeError: + pass + + # 4) First JSON array + arr_start = text.find("[") + arr_end = text.rfind("]") + if arr_start != -1 and arr_end != -1 and arr_end > arr_start: + candidate = text[arr_start : arr_end + 1] + return json.loads(candidate) + + raise json.JSONDecodeError("No JSON payload found", response, 0) + + def _fallback_points(self, user_input: str) -> list[dict[str, str]]: + clean = user_input.strip() + seeds = [] + for chunk in re.split(r"[,,。;;、\n]+", clean): + value = chunk.strip() + if 2 <= len(value) <= 24: + seeds.append(value) + if len(seeds) >= 4: + break + + if not seeds: + seeds = ["核心概念", "高频方法", "典型练习"] + + points: list[dict[str, str]] = [] + for index, seed in enumerate(seeds, start=1): + points.append( + { + "knowledge_title": f"{seed}", + "knowledge_summary": f"围绕“{seed}”建立从概念到应用的理解,并形成可执行步骤。", + "user_difficulty": "容易停留在理解层,缺少题型迁移与错因归纳。", + } + ) + if index >= 5: + break + return points diff --git a/deeptutor/agents/guide/agents/interactive_agent.py b/deeptutor/agents/guide/agents/interactive_agent.py index 56f9be6c3..ba31dc9f6 100644 --- a/deeptutor/agents/guide/agents/interactive_agent.py +++ b/deeptutor/agents/guide/agents/interactive_agent.py @@ -193,18 +193,27 @@ async def process( ) if retry_with_bug: + formatted_knowledge = user_template.format( + knowledge_title=knowledge.get("knowledge_title", ""), + knowledge_summary=knowledge.get("knowledge_summary", ""), + user_difficulty=knowledge.get("user_difficulty", ""), + generation_requirements=knowledge.get("generation_requirements", ""), + content_blueprint=knowledge.get("content_blueprint", ""), + ) user_prompt = f"""The previously generated HTML page has the following issues: {retry_with_bug} Please fix these issues and regenerate the HTML page. Original knowledge point information: -{user_template.format(**knowledge)}""" +{formatted_knowledge}""" else: user_prompt = user_template.format( knowledge_title=knowledge.get("knowledge_title", ""), knowledge_summary=knowledge.get("knowledge_summary", ""), user_difficulty=knowledge.get("user_difficulty", ""), + generation_requirements=knowledge.get("generation_requirements", ""), + content_blueprint=knowledge.get("content_blueprint", ""), ) try: diff --git a/deeptutor/agents/guide/prompts/en/interactive_agent.yaml b/deeptutor/agents/guide/prompts/en/interactive_agent.yaml index dbea00fd4..523f24c16 100644 --- a/deeptutor/agents/guide/prompts/en/interactive_agent.yaml +++ b/deeptutor/agents/guide/prompts/en/interactive_agent.yaml @@ -25,4 +25,10 @@ user_template: | Learner's likely difficulties: {user_difficulty} + Hard output requirements (must satisfy): + {generation_requirements} + + Structured content blueprint (prioritize this): + {content_blueprint} + Generate an interactive HTML learning page for this knowledge point. diff --git a/deeptutor/agents/guide/prompts/zh/interactive_agent.yaml b/deeptutor/agents/guide/prompts/zh/interactive_agent.yaml index 5bb9abe84..1d1c3f111 100644 --- a/deeptutor/agents/guide/prompts/zh/interactive_agent.yaml +++ b/deeptutor/agents/guide/prompts/zh/interactive_agent.yaml @@ -25,4 +25,10 @@ user_template: | 学习者可能遇到的困难: {user_difficulty} + 课件生成硬性要求(必须满足): + {generation_requirements} + + 结构化内容蓝图(优先按此展开): + {content_blueprint} + 请基于以上内容生成一个可交互的 HTML 学习页面。 diff --git a/deeptutor/agents/math_animator/prompts/en/code_generator_agent.yaml b/deeptutor/agents/math_animator/prompts/en/code_generator_agent.yaml index 68876f4fb..0f8b6dd1d 100644 --- a/deeptutor/agents/math_animator/prompts/en/code_generator_agent.yaml +++ b/deeptutor/agents/math_animator/prompts/en/code_generator_agent.yaml @@ -3,20 +3,7 @@ generate_system: | Produce runnable Python Manim code. Rules: - For video mode, return one complete Manim script with at least one renderable Scene subclass. - - For image mode, return YON_IMAGE anchor blocks only, and each block must contain a standalone renderable Manim script. Format: - ### YON_IMAGE_1_START ### - from manim import * - class Scene1(Scene): - def construct(self): - ... - ### YON_IMAGE_1_END ### - ### YON_IMAGE_2_START ### - from manim import * - class Scene2(Scene): - def construct(self): - ... - ### YON_IMAGE_2_END ### - Anchor numbering starts at 1. The code field must contain nothing outside YON_IMAGE anchor blocks. + - For image mode, return YON_IMAGE anchor blocks only, and each block must contain a standalone renderable Manim script. - Do not include commentary outside the JSON response. - Every geometric point, path point, and vertex passed to Manim must be 3D; do not use `[x, y]` when `[x, y, 0]` is required. - When positions come from axes or planes, prefer helpers such as `axes.c2p(...)` and `plane.c2p(...)` so the result is a valid 3D point. @@ -32,10 +19,8 @@ generate_system: | - After the final important conclusion appears, keep a stable hold of roughly 1.5-3 seconds so the viewer can actually read it. - If the resulting code would likely feel shorter than about 10 seconds, extend the pacing unless the user explicitly requested a short clip. - If the user gives an explicit target duration (for example, 10 seconds), this requirement has highest priority: explicitly budget enough `run_time` and `self.wait(...)` in code so total runtime stays close to target (recommended >= 90% of target). - - Assume local LaTeX may be unavailable. By default, do NOT use `Tex`, `MathTex`, `SingleStringMathTex`, `MarkupText`, or any LaTeX-dependent rendering path. + - Assume local LaTeX may be unavailable. By default, do NOT use `Tex`, `MathTex`, `SingleStringMathTex`, or any LaTeX-dependent rendering path. - Prefer non-LaTeX math presentation with `Text`, `DecimalNumber`, tables, labels, and geometric visualization; avoid requiring a local `latex` executable. - - Do NOT use the `stroke_dash_pattern` parameter (not supported in Manim v0.20.1); use alternative approaches for dashed lines. - - Do NOT use `self.save_state()` (not supported in MovingCameraScene); use alternative state management. - Camera movement rule: only use `self.camera.frame` when the scene class inherits `MovingCameraScene`. If the class is plain `Scene`, `self.camera.frame` must not appear. - If camera capability is uncertain, skip camera pan/zoom code and prioritize render stability. @@ -66,13 +51,10 @@ retry_system: | While repairing, do not shorten the animation just to make it run. Preserve complete teaching pacing and the necessary pauses whenever possible. If the original animation is obviously too short, you may also add the necessary waits, extra beats, or ending hold while repairing. If the user gave an explicit duration target, the repaired code should remain close to that target instead of becoming shorter. - Also avoid introducing `Tex` / `MathTex` / `MarkupText` by default unless LaTeX is explicitly required and available. - If the error mentions `stroke_dash_pattern`, remove that parameter immediately. - If the error mentions `save_state`, remove that method call immediately. + Also avoid introducing `Tex` / `MathTex` by default unless LaTeX is explicitly required and available. If the error mentions `self.camera.frame`, repair by either: 1) changing the scene base class to `MovingCameraScene`, or 2) removing camera-frame animation while keeping the teaching flow. - If the error mentions `YON_IMAGE`, the image-mode code is missing anchor markers. Wrap the code in `### YON_IMAGE_n_START ###` / `### YON_IMAGE_n_END ###` blocks and ensure the code field contains nothing outside those anchor blocks. retry_user_template: | User request: diff --git a/deeptutor/agents/math_animator/prompts/zh/code_generator_agent.yaml b/deeptutor/agents/math_animator/prompts/zh/code_generator_agent.yaml index bfcf9d37e..e5e0b6e19 100644 --- a/deeptutor/agents/math_animator/prompts/zh/code_generator_agent.yaml +++ b/deeptutor/agents/math_animator/prompts/zh/code_generator_agent.yaml @@ -3,20 +3,7 @@ generate_system: | 你要生成可运行的 Python Manim 代码。 要求: - video 模式:返回一个完整的 Manim 脚本,至少包含一个可渲染的 Scene 子类。 - - image 模式:必须严格输出若干个 YON_IMAGE 锚点代码块,每个代码块内都必须是独立可渲染的 Manim 脚本。格式如下: - ### YON_IMAGE_1_START ### - from manim import * - class Scene1(Scene): - def construct(self): - ... - ### YON_IMAGE_1_END ### - ### YON_IMAGE_2_START ### - from manim import * - class Scene2(Scene): - def construct(self): - ... - ### YON_IMAGE_2_END ### - 锚点编号从 1 开始递增。code 字段中除了 YON_IMAGE 锚点块外不能有其他代码。 + - image 模式:必须严格输出若干个 YON_IMAGE 锚点代码块,每个代码块内都必须是独立可渲染的 Manim 脚本。 - 不要输出解释性文字。 - Manim 中所有几何点、路径点、顶点坐标都必须是 3D 形式;不要传 `[x, y]`,要传 `[x, y, 0]`。 - 如果坐标来自坐标轴或数轴映射,优先使用 `axes.c2p(...)`、`plane.c2p(...)` 等返回 3D 点的方法。 @@ -32,10 +19,8 @@ generate_system: | - 最后一个关键结论出现后,默认再保留约 1.5-3 秒稳定停留,避免观众还没看清动画就结束。 - 如果最终代码的体感时长可能低于约 10 秒,除非用户明确要求短视频,否则必须继续补充步骤、停顿或收尾。 - 如果用户明确给出了目标时长(例如 10 秒),这条约束优先级最高:请在代码中显式安排足够的 `run_time` 与 `self.wait(...)`,让总时长尽量贴近目标(建议不低于目标的 90%)。 - - 当前运行环境可能没有本地 LaTeX。默认禁止使用 `Tex`、`MathTex`、`SingleStringMathTex`、`MarkupText` 与任何 LaTeX 依赖渲染链。 + - 当前运行环境可能没有本地 LaTeX。默认禁止使用 `Tex`、`MathTex`、`SingleStringMathTex` 与任何 LaTeX 依赖渲染链。 - 数学表达优先用 `Text`、`DecimalNumber`、`MathTable`(非 LaTeX 方案)或直接几何可视化来表达;不要依赖 `latex` 可执行程序。 - - 禁止使用 `stroke_dash_pattern` 参数(Manim v0.20.1 不支持),如需虚线效果请改用其他方式实现。 - - 禁止使用 `self.save_state()` 方法(MovingCameraScene 不支持),改用其他状态管理方式。 - 相机移动规则:只有在类继承 `MovingCameraScene` 时,才允许使用 `self.camera.frame`。若类是普通 `Scene`,严禁出现 `self.camera.frame`。 - 若不确定相机能力,默认不要写相机缩放/平移代码,优先保证可渲染稳定性。 @@ -67,13 +52,10 @@ retry_system: | 修复时不要为了“快点能跑”而把动画压缩得更短;尽量保留完整讲解节奏和必要停顿。 若原代码明显过短,也允许在修复时顺手补足必要的等待、展开步骤和结尾停留。 若用户给了明确时长,修复后也必须保持接近该时长,不要因为修错而把视频缩短。 - 默认禁止引入 `Tex` / `MathTex` / `MarkupText`,除非用户明确要求 LaTeX 且环境已具备 LaTeX。 - 若报错包含 `stroke_dash_pattern`,立即移除该参数。 - 若报错包含 `save_state`,立即删除该方法调用。 + 默认也禁止引入 `Tex` / `MathTex`,除非用户明确要求 LaTeX 且环境已具备 LaTeX。 若报错与 `self.camera.frame` 相关,必须二选一修复: 1) 把场景类改为 `MovingCameraScene`;或 2) 删除相机动画语句并保留原教学逻辑。 - 若报错包含 `YON_IMAGE`,说明 image 模式下代码缺少锚点标记。必须把代码重新包裹在 `### YON_IMAGE_n_START ###` / `### YON_IMAGE_n_END ###` 中,且 code 字段中除锚点块外不能有其他内容。 retry_user_template: | 用户需求: diff --git a/deeptutor/agents/math_animator/renderer.py b/deeptutor/agents/math_animator/renderer.py index 5ee89a44c..32c06dd22 100644 --- a/deeptutor/agents/math_animator/renderer.py +++ b/deeptutor/agents/math_animator/renderer.py @@ -4,9 +4,7 @@ import asyncio import re -import subprocess import sys -import threading from pathlib import Path from typing import Awaitable, Callable @@ -147,51 +145,43 @@ async def _run_manim( else: command.extend(["--format", "mp4"]) + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) await self._emit_progress( f"Started Manim process for `{scene_name}` with command: {' '.join(command)}", raw=True, ) - - # Use subprocess.Popen instead of asyncio.create_subprocess_exec - # for Windows compatibility (SelectorEventLoop doesn't support - # asyncio subprocesses). Reader threads + asyncio.Queue preserve - # real-time streaming output. - process = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - _SENTINEL = None - queue: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue() - loop = asyncio.get_running_loop() - - def _reader(stream, prefix: str) -> None: - assert stream is not None - for raw_line in stream: - line = raw_line.decode(errors="ignore").strip() - if line: - loop.call_soon_threadsafe(queue.put_nowait, (prefix, line)) - loop.call_soon_threadsafe(queue.put_nowait, _SENTINEL) - - threading.Thread(target=_reader, args=(process.stdout, "stdout"), daemon=True).start() - threading.Thread(target=_reader, args=(process.stderr, "stderr"), daemon=True).start() - stdout_lines: list[str] = [] stderr_lines: list[str] = [] - streams_open = 2 - while streams_open > 0: - item = await queue.get() - if item is _SENTINEL: - streams_open -= 1 - continue - prefix, line = item - (stdout_lines if prefix == "stdout" else stderr_lines).append(line) - await self._emit_progress(f"[{prefix}] {line}", raw=True) - - return_code = process.wait() - await self._emit_progress(f"Manim process finished with exit code {return_code}.", raw=True) - if return_code != 0: + + async def _drain_stream( + stream: asyncio.StreamReader | None, + sink: list[str], + *, + prefix: str, + ) -> None: + if stream is None: + return + while True: + raw_line = await stream.readline() + if not raw_line: + break + line = raw_line.decode(errors="ignore").strip() + if not line: + continue + sink.append(line) + await self._emit_progress(f"[{prefix}] {line}", raw=True) + + await asyncio.gather( + _drain_stream(process.stdout, stdout_lines, prefix="stdout"), + _drain_stream(process.stderr, stderr_lines, prefix="stderr"), + ) + await process.wait() + await self._emit_progress(f"Manim process finished with exit code {process.returncode}.", raw=True) + if process.returncode != 0: raise ManimRenderError( trim_error_message( "\n".join(part for part in ["\n".join(stdout_lines), "\n".join(stderr_lines)] if part) diff --git a/deeptutor/agents/question/agents/generator.py b/deeptutor/agents/question/agents/generator.py index 86f5a882a..8bcd42437 100644 --- a/deeptutor/agents/question/agents/generator.py +++ b/deeptutor/agents/question/agents/generator.py @@ -40,22 +40,18 @@ def __init__( self.tool_flags = tool_flags or {} self._tool_registry = get_tool_registry() - MAX_PREVIOUS_QUESTIONS = 20 - async def process( self, template: QuestionTemplate, user_topic: str = "", preference: str = "", history_context: str = "", - previous_questions: list[str] | None = None, ) -> QAPair: """ Generate one Q-A pair from a template in a single call. """ available_tools = self._build_available_tools_text() knowledge_context = str(template.metadata.get("knowledge_context", "")).strip() - prev_q_text = self._format_previous_questions(previous_questions) payload = await self._generate_payload( template=template, user_topic=user_topic, @@ -63,7 +59,6 @@ async def process( history_context=history_context, knowledge_context=knowledge_context, available_tools=available_tools, - previous_questions=prev_q_text, ) payload, validation = await self._validate_and_repair_payload( template=template, @@ -73,7 +68,6 @@ async def process( history_context=history_context, knowledge_context=knowledge_context, available_tools=available_tools, - previous_questions=prev_q_text, ) return QAPair( @@ -118,7 +112,6 @@ async def _generate_payload( history_context: str, knowledge_context: str, available_tools: str, - previous_questions: str = "", ) -> dict[str, Any]: system_prompt = self.get_prompt("system", "") user_prompt_template = self.get_prompt("generate", "") @@ -128,20 +121,16 @@ async def _generate_payload( "User topic: {user_topic}\n" "Preference: {preference}\n" "Conversation context: {history_context}\n" - "Previously generated questions (do not repeat):\n{previous_questions}\n" "Knowledge context: {knowledge_context}\n" "Enabled tools: {available_tools}\n\n" 'Return JSON {{"question_type":"","question":"","options":{{}},"correct_answer":"","explanation":""}}' ) - template_dict = self._strip_template_knowledge_context(template) - user_prompt = user_prompt_template.format( - template=json.dumps(template_dict, ensure_ascii=False, indent=2), + template=json.dumps(template.__dict__, ensure_ascii=False, indent=2), user_topic=user_topic, preference=preference or "(none)", history_context=history_context or "(none)", - previous_questions=previous_questions or "(none)", knowledge_context=knowledge_context or "(none)", available_tools=available_tools, ) @@ -188,7 +177,6 @@ async def _validate_and_repair_payload( history_context: str, knowledge_context: str, available_tools: str, - previous_questions: str = "", ) -> tuple[dict[str, Any], dict[str, Any]]: expected_type = self._normalize_question_type(template.question_type) normalized = self._normalize_payload_shape(expected_type, payload) @@ -205,7 +193,6 @@ async def _validate_and_repair_payload( history_context=history_context, knowledge_context=knowledge_context, available_tools=available_tools, - previous_questions=previous_questions, ) if repaired_payload: candidate = self._normalize_payload_shape(expected_type, repaired_payload) @@ -233,17 +220,14 @@ async def _repair_payload( history_context: str, knowledge_context: str, available_tools: str, - previous_questions: str = "", ) -> dict[str, Any]: expected_type = self._normalize_question_type(template.question_type) - template_dict = self._strip_template_knowledge_context(template) repair_prompt = ( "You are repairing an invalid quiz question JSON.\n\n" - f"QuestionTemplate:\n{json.dumps(template_dict, ensure_ascii=False, indent=2)}\n\n" + f"QuestionTemplate:\n{json.dumps(template.__dict__, ensure_ascii=False, indent=2)}\n\n" f"User topic:\n{user_topic or '(none)'}\n\n" f"User preference:\n{preference or '(none)'}\n\n" f"Conversation context:\n{history_context or '(none)'}\n\n" - f"Previously generated questions:\n{previous_questions or '(none)'}\n\n" f"Knowledge context:\n{knowledge_context or '(none)'}\n\n" f"Enabled tools:\n{available_tools}\n\n" f"Invalid payload:\n{json.dumps(payload, ensure_ascii=False, indent=2)}\n\n" @@ -399,25 +383,6 @@ def _enabled_tool_names(self) -> list[str]: enabled_tools.append("code_execution") return enabled_tools - @staticmethod - def _strip_template_knowledge_context(template: QuestionTemplate) -> dict[str, Any]: - """Strip knowledge_context from template metadata to avoid prompt duplication.""" - template_dict = template.__dict__.copy() - if isinstance(template_dict.get("metadata"), dict): - template_dict["metadata"] = { - k: v - for k, v in template_dict["metadata"].items() - if k != "knowledge_context" - } - return template_dict - - @classmethod - def _format_previous_questions(cls, questions: list[str] | None) -> str: - if not questions: - return "" - capped = questions[-cls.MAX_PREVIOUS_QUESTIONS :] - return "\n".join(f"{i}. {q}" for i, q in enumerate(capped, 1)) - @staticmethod def _parse_json_like(content: str) -> dict[str, Any]: if not content or not content.strip(): diff --git a/deeptutor/agents/question/agents/idea_agent.py b/deeptutor/agents/question/agents/idea_agent.py index 60608fe07..23d8cf41e 100644 --- a/deeptutor/agents/question/agents/idea_agent.py +++ b/deeptutor/agents/question/agents/idea_agent.py @@ -9,7 +9,6 @@ from typing import Any from deeptutor.agents.base_agent import BaseAgent -from deeptutor.utils.json_parser import parse_json_response from deeptutor.agents.question.models import QuestionTemplate from deeptutor.core.trace import build_trace_metadata, new_call_id from deeptutor.tools.rag_tool import rag_search @@ -228,7 +227,7 @@ async def _generate_templates( ): _chunks.append(_c) response = "".join(_chunks) - payload = parse_json_response(response, logger_instance=self.logger) + payload = json.loads(response) ideas_raw = payload.get("ideas", []) if not isinstance(ideas_raw, list): ideas_raw = [] diff --git a/deeptutor/agents/question/coordinator.py b/deeptutor/agents/question/coordinator.py index 5c1534c35..a9180077c 100644 --- a/deeptutor/agents/question/coordinator.py +++ b/deeptutor/agents/question/coordinator.py @@ -273,7 +273,6 @@ async def _generation_loop( generator = self._create_generator() results: list[dict[str, Any]] = [] total = len(templates) - generated_questions: list[str] = [] for idx, template in enumerate(templates, 1): await self._send_ws_update( @@ -293,7 +292,6 @@ async def _generation_loop( user_topic=user_topic, preference=preference, history_context=history_context, - previous_questions=generated_questions or None, ) except Exception as exc: success = False @@ -316,10 +314,6 @@ async def _generation_loop( } results.append(result) - # Track successfully generated question text for diversity enforcement - if success and qa_pair.question: - generated_questions.append(qa_pair.question) - await self._send_ws_update( "result", { diff --git a/deeptutor/agents/question/prompts/en/generator.yaml b/deeptutor/agents/question/prompts/en/generator.yaml index 8a5daadd6..6106d3e67 100644 --- a/deeptutor/agents/question/prompts/en/generator.yaml +++ b/deeptutor/agents/question/prompts/en/generator.yaml @@ -19,12 +19,6 @@ generate: | Tools enabled by the user: {available_tools} - Conversation context: - {history_context} - - Previously generated questions in this session (your new question MUST be different from all of these): - {previous_questions} - Requirements: - Keep strict alignment with template.concentration and template.difficulty. - Respect template.question_type exactly. Do not silently change it. @@ -39,7 +33,6 @@ generate: | - Provide a clear explanation. - Use the knowledge context when it helps ground the question. - Treat the enabled tools list as capability guidance only; do not mention tool names in the final output. - - Do NOT generate a question that is identical or nearly identical to any of the previously generated questions listed above. Return JSON only: {{ diff --git a/deeptutor/agents/question/prompts/zh/generator.yaml b/deeptutor/agents/question/prompts/zh/generator.yaml index 8a1af8c09..096044580 100644 --- a/deeptutor/agents/question/prompts/zh/generator.yaml +++ b/deeptutor/agents/question/prompts/zh/generator.yaml @@ -19,12 +19,6 @@ generate: | 用户当前启用的工具: {available_tools} - 对话上下文: - {history_context} - - 本次会话中已生成的题目(你的新题目必须与以下所有题目不同): - {previous_questions} - 要求: - 严格对齐 template.concentration 与 template.difficulty。 - 必须严格遵循 template.question_type,不要私自改题型。 @@ -38,7 +32,6 @@ generate: | - 给出清晰解释。 - 在有帮助时使用知识上下文增强题目质量。 - 可将启用工具列表视为能力提示,但不要在最终题目中直接提及工具名称。 - - 不要生成与上面已生成题目相同或高度相似的题目。 仅返回 JSON: {{ diff --git a/deeptutor/agents/research/agents/note_agent.py b/deeptutor/agents/research/agents/note_agent.py index f61fcd1e0..fca86cdd4 100644 --- a/deeptutor/agents/research/agents/note_agent.py +++ b/deeptutor/agents/research/agents/note_agent.py @@ -11,7 +11,6 @@ from deeptutor.agents.base_agent import BaseAgent from deeptutor.agents.research.data_structures import ToolTrace from deeptutor.core.trace import build_trace_metadata, new_call_id -from deeptutor.utils.json_parser import parse_json_response from ..utils.json_utils import extract_json_from_text @@ -159,8 +158,9 @@ def _get_mode_instruction_text(self, stage: str) -> str: def _extract_summary_by_rule(self, tool_type: str, raw_answer: str) -> str: """Extract a concise summary from structured tool output without LLM.""" - data = parse_json_response(raw_answer, fallback=None) - if data is None: + try: + data = json.loads(raw_answer) + except Exception: return "" tool_type = (tool_type or "").lower() diff --git a/deeptutor/agents/research/agents/reporting_agent.py b/deeptutor/agents/research/agents/reporting_agent.py index ce0e8c896..a1ab7a315 100644 --- a/deeptutor/agents/research/agents/reporting_agent.py +++ b/deeptutor/agents/research/agents/reporting_agent.py @@ -1507,13 +1507,15 @@ def _assemble_markdown_from_structured(obj: dict[str, Any]) -> str: @staticmethod def _strip_json_wrapper(resp: str) -> str: """Best-effort extraction of readable text from a JSON response.""" - from deeptutor.utils.json_parser import parse_json_response - - obj = parse_json_response(resp.strip(), fallback=None) - if isinstance(obj, dict): - for key in ("report", "content", "text", "markdown", "output"): - if key in obj and isinstance(obj[key], str): - return obj[key] + import json as _json + try: + obj = _json.loads(resp.strip()) + if isinstance(obj, dict): + for key in ("report", "content", "text", "markdown", "output"): + if key in obj and isinstance(obj[key], str): + return obj[key] + except (ValueError, TypeError): + pass stripped = resp.strip() if stripped.startswith("{") or stripped.startswith("["): for line in stripped.split("\\n"): diff --git a/deeptutor/agents/research/data_structures.py b/deeptutor/agents/research/data_structures.py index aab72105f..0fc242639 100644 --- a/deeptutor/agents/research/data_structures.py +++ b/deeptutor/agents/research/data_structures.py @@ -11,8 +11,6 @@ from pathlib import Path from typing import Any -from deeptutor.utils.json_parser import parse_json_response - class TopicStatus(Enum): """Topic block status enumeration""" @@ -80,27 +78,31 @@ def _truncate_raw_answer(raw_answer: str, max_size: int) -> str: return raw_answer # Try to parse as JSON and truncate intelligently - data = parse_json_response(raw_answer, fallback=None) - if isinstance(data, dict): - content_fields = ["answer", "content", "text", "chunks", "documents"] - for field_name in content_fields: - if field_name in data: - if ( - isinstance(data[field_name], str) - and len(data[field_name]) > max_size // 2 - ): - data[field_name] = data[field_name][: max_size // 2] + "... [truncated]" - elif isinstance(data[field_name], list): - data[field_name] = data[field_name][:3] - if data[field_name]: - data[field_name].append({"note": "... additional items truncated"}) + try: + data = json.loads(raw_answer) + + # If it's a dict with common RAG response fields, truncate content fields + if isinstance(data, dict): + # Truncate long content fields + content_fields = ["answer", "content", "text", "chunks", "documents"] + for field_name in content_fields: + if field_name in data: + if ( + isinstance(data[field_name], str) + and len(data[field_name]) > max_size // 2 + ): + data[field_name] = data[field_name][: max_size // 2] + "... [truncated]" + elif isinstance(data[field_name], list): + # Keep only first few items + data[field_name] = data[field_name][:3] + if data[field_name]: + data[field_name].append({"note": "... additional items truncated"}) - try: truncated = json.dumps(data, ensure_ascii=False) if len(truncated) <= max_size: return truncated - except (TypeError, ValueError): - pass + except (json.JSONDecodeError, TypeError): + pass # Fallback: simple truncation with marker truncation_marker = "\n... [content truncated, original size: {} bytes]".format( diff --git a/deeptutor/agents/research/utils/citation_manager.py b/deeptutor/agents/research/utils/citation_manager.py index a8937abf9..8b0f00fad 100644 --- a/deeptutor/agents/research/utils/citation_manager.py +++ b/deeptutor/agents/research/utils/citation_manager.py @@ -11,7 +11,6 @@ from typing import Any from deeptutor.services.path_service import get_path_service -from deeptutor.utils.json_parser import parse_json_response class CitationManager: @@ -294,7 +293,7 @@ def _extract_rag_citation( try: # Parse raw_answer to extract source information - answer_data = parse_json_response(raw_answer) + answer_data = json.loads(raw_answer) # Extract source documents if available # Common fields in RAG responses: chunks, documents, sources, context @@ -350,7 +349,7 @@ def _extract_web_citation( try: # Parse raw_answer to extract web source information - answer_data = parse_json_response(raw_answer) + answer_data = json.loads(raw_answer) web_sources = [] @@ -397,7 +396,7 @@ def _extract_paper_citation( try: # Parse raw_answer JSON - answer_data = parse_json_response(raw_answer) + answer_data = json.loads(raw_answer) papers = answer_data.get("papers", []) if not papers: diff --git a/deeptutor/agents/research/utils/token_tracker.py b/deeptutor/agents/research/utils/token_tracker.py index 49a013509..6909e8d55 100644 --- a/deeptutor/agents/research/utils/token_tracker.py +++ b/deeptutor/agents/research/utils/token_tracker.py @@ -18,7 +18,15 @@ TIKTOKEN_AVAILABLE = False tiktoken = None # type: ignore -LITELLM_AVAILABLE = False +# Try importing litellm (optional) +try: + import litellm # type: ignore + from litellm import token_counter # type: ignore + + LITELLM_AVAILABLE = True +except ImportError: + LITELLM_AVAILABLE = False + litellm = None # type: ignore # Model pricing table (USD per 1K tokens) MODEL_PRICING = { @@ -55,13 +63,11 @@ def count_tokens_with_tiktoken(text: str, model_name: str) -> int: def count_tokens_with_litellm(messages: list[dict], model_name: str) -> dict[str, int]: - """Count tokens from messages using tiktoken (litellm removed).""" - if not TIKTOKEN_AVAILABLE: + if not LITELLM_AVAILABLE: return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} try: - text = "\n".join(str(m.get("content", "")) for m in messages) - count = count_tokens_with_tiktoken(text, model_name) - return {"prompt_tokens": count, "completion_tokens": 0, "total_tokens": count} + token_count = token_counter(model=model_name, messages=messages) # type: ignore + return {"prompt_tokens": token_count, "completion_tokens": 0, "total_tokens": token_count} except Exception: return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} @@ -74,6 +80,17 @@ def get_model_pricing(model_name: str) -> dict[str, float]: for key, val in MODEL_PRICING.items(): if key in lower or lower in key: return val + # litellm query + if LITELLM_AVAILABLE: + try: + info = litellm.get_model_info(model=model_name) # type: ignore + if info and "input_cost_per_token" in info: + return { + "input": info.get("input_cost_per_token", 0) * 1000, + "output": info.get("output_cost_per_token", 0) * 1000, + } + except Exception: + pass return MODEL_PRICING["gpt-4o-mini"] diff --git a/deeptutor/agents/solve/agents/planner_agent.py b/deeptutor/agents/solve/agents/planner_agent.py index 20cee3c12..3c31a954a 100644 --- a/deeptutor/agents/solve/agents/planner_agent.py +++ b/deeptutor/agents/solve/agents/planner_agent.py @@ -15,7 +15,6 @@ from deeptutor.agents.base_agent import BaseAgent from deeptutor.core.trace import build_trace_metadata, derive_trace_metadata, new_call_id -from deeptutor.utils.json_parser import parse_json_response from ..memory.scratchpad import Plan, PlanStep, Scratchpad from ..tool_runtime import SolveToolRuntime @@ -215,7 +214,7 @@ async def _generate_search_queries( ): parts.append(chunk) response = "".join(parts) - payload = parse_json_response(response, logger_instance=logger) + payload = json.loads(response) queries = payload.get("queries", []) if not isinstance(queries, list): queries = [] diff --git a/deeptutor/agents/solve/utils/token_tracker.py b/deeptutor/agents/solve/utils/token_tracker.py index ae6ca65c7..8188217a2 100644 --- a/deeptutor/agents/solve/utils/token_tracker.py +++ b/deeptutor/agents/solve/utils/token_tracker.py @@ -18,7 +18,15 @@ TIKTOKEN_AVAILABLE = False tiktoken = None -LITELLM_AVAILABLE = False +# Try importing litellm (optional advanced library) +try: + import litellm + from litellm import token_counter + + LITELLM_AVAILABLE = True +except ImportError: + LITELLM_AVAILABLE = False + litellm = None # Model pricing table (price per 1K tokens, unit: USD) @@ -95,20 +103,56 @@ def count_tokens_with_tiktoken(text: str, model_name: str) -> int: def count_tokens_with_litellm(messages: list[dict], model_name: str) -> dict[str, int]: - """Count tokens from messages using tiktoken (litellm removed).""" - if not TIKTOKEN_AVAILABLE: + """ + Calculate token count using litellm (if available) + + Args: + messages: Message list + model_name: Model name + + Returns: + {'prompt_tokens': int, 'completion_tokens': int, 'total_tokens': int} + """ + if not LITELLM_AVAILABLE: return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + try: - text = "\n".join(str(m.get("content", "")) for m in messages) - count = count_tokens_with_tiktoken(text, model_name) - return {"prompt_tokens": count, "completion_tokens": 0, "total_tokens": count} + # Use litellm's token_counter + token_count = token_counter(model=model_name, messages=messages) + return { + "prompt_tokens": token_count, + "completion_tokens": 0, # litellm only counts prompt tokens + "total_tokens": token_count, + } except Exception: return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} def calculate_cost_with_litellm(model: str, prompt_tokens: int, completion_tokens: int) -> float: - """Calculate cost using built-in pricing table.""" - return calculate_cost(model, prompt_tokens, completion_tokens) + """ + Calculate cost using litellm (more accurate if available) + + Args: + model: Model name + prompt_tokens: Input tokens + completion_tokens: Output tokens + + Returns: + Cost (USD) + """ + if not LITELLM_AVAILABLE: + # Fall back to manual calculation + return calculate_cost(model, prompt_tokens, completion_tokens) + + try: + # Use litellm's completion_cost function + cost = litellm.completion_cost( + model=model, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens + ) + return cost + except Exception: + # If failed, fall back to manual calculation + return calculate_cost(model, prompt_tokens, completion_tokens) def get_model_pricing(model_name: str) -> dict[str, float]: @@ -131,6 +175,20 @@ def get_model_pricing(model_name: str) -> dict[str, float]: if key.lower() in model_lower or model_lower in key.lower(): return pricing + # If using litellm, try to get price from litellm + if LITELLM_AVAILABLE: + try: + # litellm has built-in pricing table + model_info = litellm.get_model_info(model=model_name) + if model_info and "input_cost_per_token" in model_info: + return { + "input": model_info.get("input_cost_per_token", 0) * 1000, + "output": model_info.get("output_cost_per_token", 0) * 1000, + } + except Exception: + pass + + # Default price (use gpt-4o-mini as conservative estimate) return MODEL_PRICING.get("gpt-4o-mini", {"input": 0.00015, "output": 0.0006}) diff --git a/deeptutor/agents/visualize/__init__.py b/deeptutor/agents/visualize/__init__.py deleted file mode 100644 index 1e1e9ee43..000000000 --- a/deeptutor/agents/visualize/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Visualize agents and pipeline.""" - -from .pipeline import VisualizePipeline - -__all__ = ["VisualizePipeline"] diff --git a/deeptutor/agents/visualize/agents/__init__.py b/deeptutor/agents/visualize/agents/__init__.py deleted file mode 100644 index 1dd2f0133..000000000 --- a/deeptutor/agents/visualize/agents/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Agent building blocks for the visualize capability.""" - -from .analysis_agent import AnalysisAgent -from .code_generator_agent import CodeGeneratorAgent -from .review_agent import ReviewAgent - -__all__ = [ - "AnalysisAgent", - "CodeGeneratorAgent", - "ReviewAgent", -] diff --git a/deeptutor/agents/visualize/agents/analysis_agent.py b/deeptutor/agents/visualize/agents/analysis_agent.py deleted file mode 100644 index 4265e685d..000000000 --- a/deeptutor/agents/visualize/agents/analysis_agent.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Analysis stage: decide SVG vs Chart.js and produce a structured brief.""" - -from __future__ import annotations - -from deeptutor.agents.base_agent import BaseAgent -from deeptutor.core.trace import build_trace_metadata, new_call_id - -from ..models import VisualizationAnalysis -from ..utils import extract_json_object - - -class AnalysisAgent(BaseAgent): - def __init__( - self, - api_key: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - language: str = "zh", - ) -> None: - super().__init__( - module_name="visualize", - agent_name="analysis_agent", - api_key=api_key, - base_url=base_url, - api_version=api_version, - language=language, - ) - - async def process( - self, - *, - user_input: str, - history_context: str, - render_mode: str = "auto", - ) -> VisualizationAnalysis: - if render_mode in ("svg", "chartjs"): - system_prompt = self.get_prompt("system_fixed") - user_template = self.get_prompt("user_template_fixed") - else: - system_prompt = self.get_prompt("system") - user_template = self.get_prompt("user_template") - if not system_prompt or not user_template: - raise ValueError("AnalysisAgent prompts are not configured.") - - format_kwargs: dict[str, str] = { - "user_input": user_input.strip(), - "history_context": history_context.strip() or "(none)", - } - if render_mode in ("svg", "chartjs"): - format_kwargs["render_type"] = render_mode - - user_prompt = user_template.format(**format_kwargs) - - chunks: list[str] = [] - async for chunk in self.stream_llm( - user_prompt=user_prompt, - system_prompt=system_prompt, - response_format={"type": "json_object"}, - stage="analyzing", - trace_meta=build_trace_metadata( - call_id=new_call_id("viz-analysis"), - phase="analyzing", - label="Visualization analysis", - call_kind="viz_analysis", - trace_role="analyze", - trace_kind="llm_output", - ), - ): - chunks.append(chunk) - response = "".join(chunks) - result = VisualizationAnalysis.model_validate(extract_json_object(response)) - if render_mode in ("svg", "chartjs"): - result.render_type = render_mode # type: ignore[assignment] - return result diff --git a/deeptutor/agents/visualize/agents/code_generator_agent.py b/deeptutor/agents/visualize/agents/code_generator_agent.py deleted file mode 100644 index e10c01688..000000000 --- a/deeptutor/agents/visualize/agents/code_generator_agent.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Code generation stage: produce SVG or Chart.js code from the analysis.""" - -from __future__ import annotations - -import json - -from deeptutor.agents.base_agent import BaseAgent -from deeptutor.core.trace import build_trace_metadata, new_call_id - -from ..models import VisualizationAnalysis -from ..utils import extract_code_block - - -class CodeGeneratorAgent(BaseAgent): - def __init__( - self, - api_key: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - language: str = "zh", - ) -> None: - super().__init__( - module_name="visualize", - agent_name="code_generator_agent", - api_key=api_key, - base_url=base_url, - api_version=api_version, - language=language, - ) - - async def process( - self, - *, - user_input: str, - history_context: str, - analysis: VisualizationAnalysis, - ) -> str: - system_prompt = self.get_prompt("system") - user_template = self.get_prompt("user_template") - if not system_prompt or not user_template: - raise ValueError("CodeGeneratorAgent prompts are not configured.") - - user_prompt = user_template.format( - user_input=user_input.strip(), - history_context=history_context.strip() or "(none)", - render_type=analysis.render_type, - analysis_json=json.dumps(analysis.model_dump(), ensure_ascii=False, indent=2), - ) - - chunks: list[str] = [] - async for chunk in self.stream_llm( - user_prompt=user_prompt, - system_prompt=system_prompt, - stage="generating", - trace_meta=build_trace_metadata( - call_id=new_call_id("viz-codegen"), - phase="generating", - label="Code generation", - call_kind="viz_code_generation", - trace_role="generate", - trace_kind="llm_output", - ), - ): - chunks.append(chunk) - response = "".join(chunks) - - lang_hint = "svg" if analysis.render_type == "svg" else "javascript" - return extract_code_block(response, lang_hint) or extract_code_block(response) diff --git a/deeptutor/agents/visualize/agents/review_agent.py b/deeptutor/agents/visualize/agents/review_agent.py deleted file mode 100644 index 6391c4aed..000000000 --- a/deeptutor/agents/visualize/agents/review_agent.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Review stage: check and optionally optimise the generated code.""" - -from __future__ import annotations - -import json - -from deeptutor.agents.base_agent import BaseAgent -from deeptutor.core.trace import build_trace_metadata, new_call_id - -from ..models import ReviewResult, VisualizationAnalysis -from ..utils import extract_json_object - - -class ReviewAgent(BaseAgent): - def __init__( - self, - api_key: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - language: str = "zh", - ) -> None: - super().__init__( - module_name="visualize", - agent_name="review_agent", - api_key=api_key, - base_url=base_url, - api_version=api_version, - language=language, - ) - - async def process( - self, - *, - user_input: str, - analysis: VisualizationAnalysis, - code: str, - ) -> ReviewResult: - system_prompt = self.get_prompt("system") - user_template = self.get_prompt("user_template") - if not system_prompt or not user_template: - raise ValueError("ReviewAgent prompts are not configured.") - - user_prompt = user_template.format( - user_input=user_input.strip(), - render_type=analysis.render_type, - analysis_json=json.dumps(analysis.model_dump(), ensure_ascii=False, indent=2), - code=code, - ) - - chunks: list[str] = [] - async for chunk in self.stream_llm( - user_prompt=user_prompt, - system_prompt=system_prompt, - response_format={"type": "json_object"}, - stage="reviewing", - trace_meta=build_trace_metadata( - call_id=new_call_id("viz-review"), - phase="reviewing", - label="Code review", - call_kind="viz_code_review", - trace_role="review", - trace_kind="llm_output", - ), - ): - chunks.append(chunk) - response = "".join(chunks) - return ReviewResult.model_validate(extract_json_object(response)) diff --git a/deeptutor/agents/visualize/models.py b/deeptutor/agents/visualize/models.py deleted file mode 100644 index ae1d3eae7..000000000 --- a/deeptutor/agents/visualize/models.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Data models for the visualize pipeline.""" - -from __future__ import annotations - -from typing import Literal - -from pydantic import BaseModel, Field - - -class VisualizationAnalysis(BaseModel): - """Output of the analysis stage.""" - - render_type: Literal["svg", "chartjs"] = Field( - description="Whether to render as raw SVG or as a Chart.js configuration.", - ) - description: str = Field( - default="", - description="High-level description of what the visualization should show.", - ) - data_description: str = Field( - default="", - description="Description of the data or elements to be visualized.", - ) - chart_type: str = Field( - default="", - description="Chart.js chart type (bar, line, pie, doughnut, radar, etc.) when render_type is chartjs.", - ) - visual_elements: list[str] = Field( - default_factory=list, - description="Key visual elements to include (shapes, labels, axes, colors, etc.).", - ) - rationale: str = Field( - default="", - description="Why this render_type was chosen over the alternative.", - ) - - -class ReviewResult(BaseModel): - """Output of the review / optimization stage.""" - - optimized_code: str = Field( - description="The final (potentially optimized) visualization code.", - ) - changed: bool = Field( - default=False, - description="Whether the reviewer made modifications.", - ) - review_notes: str = Field( - default="", - description="Notes on what was checked or changed.", - ) diff --git a/deeptutor/agents/visualize/pipeline.py b/deeptutor/agents/visualize/pipeline.py deleted file mode 100644 index 4724e041b..000000000 --- a/deeptutor/agents/visualize/pipeline.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Orchestrates the three-stage visualization generation flow.""" - -from __future__ import annotations - -from typing import Any, Callable - -from .agents import AnalysisAgent, CodeGeneratorAgent, ReviewAgent -from .models import ReviewResult, VisualizationAnalysis - - -class VisualizePipeline: - def __init__( - self, - *, - api_key: str | None, - base_url: str | None, - api_version: str | None, - language: str = "zh", - trace_callback: Callable[[dict[str, Any]], Any] | None = None, - ) -> None: - self.analysis_agent = AnalysisAgent( - api_key=api_key, - base_url=base_url, - api_version=api_version, - language=language, - ) - self.code_agent = CodeGeneratorAgent( - api_key=api_key, - base_url=base_url, - api_version=api_version, - language=language, - ) - self.review_agent = ReviewAgent( - api_key=api_key, - base_url=base_url, - api_version=api_version, - language=language, - ) - self.set_trace_callback(trace_callback) - - def set_trace_callback(self, callback: Callable[[dict[str, Any]], Any] | None) -> None: - for agent in (self.analysis_agent, self.code_agent, self.review_agent): - agent.set_trace_callback(callback) - - async def run_analysis( - self, - *, - user_input: str, - history_context: str, - render_mode: str = "auto", - ) -> VisualizationAnalysis: - return await self.analysis_agent.process( - user_input=user_input, - history_context=history_context, - render_mode=render_mode, - ) - - async def run_code_generation( - self, - *, - user_input: str, - history_context: str, - analysis: VisualizationAnalysis, - ) -> str: - return await self.code_agent.process( - user_input=user_input, - history_context=history_context, - analysis=analysis, - ) - - async def run_review( - self, - *, - user_input: str, - analysis: VisualizationAnalysis, - code: str, - ) -> ReviewResult: - return await self.review_agent.process( - user_input=user_input, - analysis=analysis, - code=code, - ) - - -__all__ = ["VisualizePipeline"] diff --git a/deeptutor/agents/visualize/prompts/en/analysis_agent.yaml b/deeptutor/agents/visualize/prompts/en/analysis_agent.yaml deleted file mode 100644 index 90ecad3dd..000000000 --- a/deeptutor/agents/visualize/prompts/en/analysis_agent.yaml +++ /dev/null @@ -1,55 +0,0 @@ -system: | - You are a visualization analyst. Given a user request and conversation history, - decide the best rendering approach and produce a structured analysis. - - Choose between two render types: - - "svg": Use when the visualization is a diagram, flowchart, illustration, schematic, - concept map, architecture diagram, or any free-form graphic that does not represent - quantitative data in a standard chart form. - - "chartjs": Use when the visualization involves quantitative data that fits a - standard chart type (bar, line, pie, doughnut, radar, scatter, bubble, polar area). - - Return your analysis as a single JSON object. Do not include any text outside the JSON. - -user_template: | - User request: - {user_input} - - Conversation history: - {history_context} - - Return JSON: - {{ - "render_type": "svg" or "chartjs", - "description": "High-level description of what the visualization should show", - "data_description": "Description of the data or elements to be visualized", - "chart_type": "Chart.js chart type if render_type is chartjs, otherwise empty string", - "visual_elements": ["key visual elements to include"], - "rationale": "Why this render_type was chosen" - }} - -system_fixed: | - You are a visualization analyst. The user has already chosen the rendering type. - Analyze the user request and conversation history to produce a structured brief - for code generation. Do not change the render_type. - - Return your analysis as a single JSON object. Do not include any text outside the JSON. - -user_template_fixed: | - User request: - {user_input} - - Conversation history: - {history_context} - - The render type is fixed to: {render_type} - - Return JSON: - {{ - "render_type": "{render_type}", - "description": "High-level description of what the visualization should show", - "data_description": "Description of the data or elements to be visualized", - "chart_type": "Chart.js chart type if render_type is chartjs, otherwise empty string", - "visual_elements": ["key visual elements to include"], - "rationale": "Analysis notes" - }} diff --git a/deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml b/deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml deleted file mode 100644 index 8f16bca78..000000000 --- a/deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml +++ /dev/null @@ -1,32 +0,0 @@ -system: | - You are a visualization code generator. Based on the analysis, user request, and - conversation history, produce the visualization code. - - Rules: - - If render_type is "svg", output a complete, self-contained SVG string. - The SVG must include the xmlns attribute and be well-formed XML. - Use viewBox for responsive sizing. Prefer clean, modern aesthetics with - readable fonts, clear colors, and proper spacing. - - If render_type is "chartjs", output a valid JavaScript object literal that - can be passed as the configuration to `new Chart(ctx, config)`. - The config must include `type`, `data`, and `options` fields. - Use modern color palettes and ensure labels are readable. - - Do NOT include any explanation outside the code fence. - - Wrap the code in a fenced code block with the appropriate language tag - (```svg or ```javascript). - -user_template: | - User request: - {user_input} - - Conversation history: - {history_context} - - Render type: {render_type} - - Analysis: - {analysis_json} - - Generate the visualization code now. Wrap it in a fenced code block: - - For SVG: ```svg ... ``` - - For Chart.js: ```javascript ... ``` diff --git a/deeptutor/agents/visualize/prompts/en/review_agent.yaml b/deeptutor/agents/visualize/prompts/en/review_agent.yaml deleted file mode 100644 index 44a6ccb5a..000000000 --- a/deeptutor/agents/visualize/prompts/en/review_agent.yaml +++ /dev/null @@ -1,41 +0,0 @@ -system: | - You are a visualization code reviewer. Examine the generated code for correctness, - completeness, and visual quality. Fix any issues you find. - - For SVG code, check: - - Well-formed XML with proper xmlns - - Correct viewBox and dimensions - - Text readability (font size, contrast) - - Proper use of colors and spacing - - Missing or broken elements - - For Chart.js code, check: - - Valid JavaScript object literal - - Correct chart type and data structure - - Proper labels, datasets, and options - - Color accessibility and readability - - Responsive configuration - - If the code is already good, return it unchanged with changed=false. - If you make improvements, return the optimized code with changed=true. - - Return a JSON object. Do not include any text outside the JSON. - -user_template: | - User request: - {user_input} - - Render type: {render_type} - - Analysis: - {analysis_json} - - Code to review: - {code} - - Return JSON: - {{ - "optimized_code": "the final code (same as input if no changes needed)", - "changed": true or false, - "review_notes": "what was checked or changed" - }} diff --git a/deeptutor/agents/visualize/prompts/zh/analysis_agent.yaml b/deeptutor/agents/visualize/prompts/zh/analysis_agent.yaml deleted file mode 100644 index 5bd7248da..000000000 --- a/deeptutor/agents/visualize/prompts/zh/analysis_agent.yaml +++ /dev/null @@ -1,51 +0,0 @@ -system: | - 你是一个可视化分析师。根据用户请求和对话历史,决定最佳的渲染方式并生成结构化分析。 - - 在两种渲染类型中选择: - - "svg":当可视化是流程图、示意图、概念图、架构图或任何不适合标准图表形式的自由图形时使用。 - - "chartjs":当可视化涉及可以用标准图表类型(柱状图、折线图、饼图、环形图、雷达图、散点图、气泡图、极坐标图)表示的定量数据时使用。 - - 返回一个 JSON 对象,不要包含 JSON 以外的任何文本。 - -user_template: | - 用户请求: - {user_input} - - 对话历史: - {history_context} - - 返回 JSON: - {{ - "render_type": "svg" 或 "chartjs", - "description": "可视化应展示的内容的高层描述", - "data_description": "要可视化的数据或元素的描述", - "chart_type": "如果 render_type 是 chartjs 则填写 Chart.js 图表类型,否则为空字符串", - "visual_elements": ["需要包含的关键视觉元素"], - "rationale": "为什么选择这种 render_type" - }} - -system_fixed: | - 你是一个可视化分析师。用户已经选择了渲染类型。 - 分析用户请求和对话历史,生成结构化的代码生成简报。 - 不要更改 render_type。 - - 返回一个 JSON 对象,不要包含 JSON 以外的任何文本。 - -user_template_fixed: | - 用户请求: - {user_input} - - 对话历史: - {history_context} - - 渲染类型已固定为:{render_type} - - 返回 JSON: - {{ - "render_type": "{render_type}", - "description": "可视化应展示的内容的高层描述", - "data_description": "要可视化的数据或元素的描述", - "chart_type": "如果 render_type 是 chartjs 则填写 Chart.js 图表类型,否则为空字符串", - "visual_elements": ["需要包含的关键视觉元素"], - "rationale": "分析说明" - }} diff --git a/deeptutor/agents/visualize/prompts/zh/code_generator_agent.yaml b/deeptutor/agents/visualize/prompts/zh/code_generator_agent.yaml deleted file mode 100644 index 97f4488cd..000000000 --- a/deeptutor/agents/visualize/prompts/zh/code_generator_agent.yaml +++ /dev/null @@ -1,30 +0,0 @@ -system: | - 你是一个可视化代码生成器。根据分析结果、用户请求和对话历史生成可视化代码。 - - 规则: - - 如果 render_type 是 "svg",输出完整的、自包含的 SVG 字符串。 - SVG 必须包含 xmlns 属性并且是格式良好的 XML。 - 使用 viewBox 实现响应式尺寸。优先使用简洁现代的美学风格, - 确保字体可读、颜色清晰、间距合适。 - - 如果 render_type 是 "chartjs",输出一个有效的 JavaScript 对象字面量, - 可以作为配置传递给 `new Chart(ctx, config)`。 - 配置必须包含 `type`、`data` 和 `options` 字段。 - 使用现代配色方案并确保标签可读。 - - 不要在代码块之外包含任何解释。 - - 用带有适当语言标签的代码块包裹代码(```svg 或 ```javascript)。 - -user_template: | - 用户请求: - {user_input} - - 对话历史: - {history_context} - - 渲染类型:{render_type} - - 分析: - {analysis_json} - - 现在生成可视化代码。用代码块包裹: - - SVG 使用:```svg ... ``` - - Chart.js 使用:```javascript ... ``` diff --git a/deeptutor/agents/visualize/prompts/zh/review_agent.yaml b/deeptutor/agents/visualize/prompts/zh/review_agent.yaml deleted file mode 100644 index d584abe10..000000000 --- a/deeptutor/agents/visualize/prompts/zh/review_agent.yaml +++ /dev/null @@ -1,40 +0,0 @@ -system: | - 你是一个可视化代码审查员。检查生成的代码的正确性、完整性和视觉质量,修复发现的任何问题。 - - 对于 SVG 代码,检查: - - 格式良好的 XML,包含正确的 xmlns - - 正确的 viewBox 和尺寸 - - 文本可读性(字体大小、对比度) - - 颜色和间距的合理使用 - - 缺失或损坏的元素 - - 对于 Chart.js 代码,检查: - - 有效的 JavaScript 对象字面量 - - 正确的图表类型和数据结构 - - 正确的标签、数据集和选项 - - 颜色的可访问性和可读性 - - 响应式配置 - - 如果代码已经很好,原样返回并设置 changed=false。 - 如果你做了改进,返回优化后的代码并设置 changed=true。 - - 返回一个 JSON 对象,不要包含 JSON 以外的任何文本。 - -user_template: | - 用户请求: - {user_input} - - 渲染类型:{render_type} - - 分析: - {analysis_json} - - 待审查代码: - {code} - - 返回 JSON: - {{ - "optimized_code": "最终代码(如果无需更改则与输入相同)", - "changed": true 或 false, - "review_notes": "检查或修改的内容" - }} diff --git a/deeptutor/agents/visualize/utils.py b/deeptutor/agents/visualize/utils.py deleted file mode 100644 index 722d56ad4..000000000 --- a/deeptutor/agents/visualize/utils.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Utility helpers for the visualize pipeline.""" - -from __future__ import annotations - -import json -import re -from typing import Any - - -def extract_json_object(text: str) -> dict[str, Any]: - """Extract a JSON object from raw model output.""" - raw = (text or "").strip() - if not raw: - return {} - - fenced = re.findall(r"```(?:json)?\s*([\s\S]*?)\s*```", raw) - candidates = fenced + [raw] - - for candidate in candidates: - try: - parsed = json.loads(candidate) - if isinstance(parsed, dict): - return parsed - except json.JSONDecodeError: - parsed = _decode_first_json_object(candidate) - if parsed is not None: - return parsed - - start = raw.find("{") - end = raw.rfind("}") - if start != -1 and end != -1 and end > start: - snippet = raw[start : end + 1] - try: - return json.loads(snippet) - except json.JSONDecodeError: - parsed = _decode_first_json_object(snippet) - if parsed is not None: - return parsed - - raise json.JSONDecodeError("No JSON object found", raw, 0) - - -def _decode_first_json_object(text: str) -> dict[str, Any] | None: - decoder = json.JSONDecoder() - stripped = (text or "").lstrip() - if not stripped: - return None - - starts = [0] - brace_index = stripped.find("{") - if brace_index > 0: - starts.append(brace_index) - - for start in starts: - try: - parsed, _end = decoder.raw_decode(stripped[start:]) - except json.JSONDecodeError: - continue - if isinstance(parsed, dict): - return parsed - return None - - -def extract_code_block(text: str, language: str = "") -> str: - """Extract a fenced code block from LLM output. - - If *language* is given the block must start with that tag; - otherwise any triple-backtick fence is accepted. - """ - if language: - pattern = rf"```{re.escape(language)}\s*\n([\s\S]*?)\n```" - else: - pattern = r"```[A-Za-z]*\s*\n([\s\S]*?)\n```" - match = re.search(pattern, text or "", re.IGNORECASE) - if match: - return match.group(1).strip() - return (text or "").strip() - - -__all__ = [ - "extract_code_block", - "extract_json_object", -] diff --git a/deeptutor/api/main.py b/deeptutor/api/main.py index 5ee7c6d12..3d406048b 100644 --- a/deeptutor/api/main.py +++ b/deeptutor/api/main.py @@ -14,17 +14,23 @@ logger = get_logger("API") -class _SuppressWsNoise(logging.Filter): - """Suppress noisy uvicorn logs for WebSocket connection churn.""" +class _ProgressWsAccessFilter(logging.Filter): + """Suppress noisy uvicorn access logs for progress WebSocket endpoints. - _SUPPRESSED = ("connection open", "connection closed") + These endpoints are polled every few seconds by the frontend for every KB, + generating hundreds of ``connection open`` / ``connection closed`` lines + that drown out useful output. + """ + + _SUPPRESSED_FRAGMENTS = ("progress/ws", "connection open", "connection closed") def filter(self, record: logging.LogRecord) -> bool: msg = record.getMessage() - return not any(f in msg for f in self._SUPPRESSED) + return not any(f in msg for f in self._SUPPRESSED_FRAGMENTS) -logging.getLogger("uvicorn.error").addFilter(_SuppressWsNoise()) +for _uv_name in ("uvicorn.access", "uvicorn.error"): + logging.getLogger(_uv_name).addFilter(_ProgressWsAccessFilter()) CONFIG_DRIFT_ERROR_TEMPLATE = ( "Configuration Drift Detected: Capability tool references {drift} are not " @@ -142,28 +148,10 @@ async def lifespan(app: FastAPI): # Disable automatic trailing slash redirects to prevent protocol downgrade issues # when deployed behind HTTPS reverse proxies (e.g., nginx). # Without this, FastAPI's 307 redirects may change HTTPS to HTTP. - # See: https://github.com/HKUDS/DeepTutor/issues/112 + # See: https://github.com/HKUDS/TYUT-X/issues/112 redirect_slashes=False, ) -# Log only non-200 requests (uvicorn access_log is disabled in run_server.py) -_access_logger = logging.getLogger("uvicorn.access") - - -@app.middleware("http") -async def selective_access_log(request, call_next): - response = await call_next(request) - if response.status_code != 200: - _access_logger.info( - '%s - "%s %s" %d', - request.client.host if request.client else "-", - request.method, - request.url.path, - response.status_code, - ) - return response - - # Configure CORS app.add_middleware( CORSMiddleware, @@ -201,6 +189,7 @@ async def selective_access_log(request, call_next): chat, co_writer, dashboard, + goal, guide, knowledge, memory, @@ -222,6 +211,7 @@ async def selective_access_log(request, call_next): app.include_router(question.router, prefix="/api/v1/question", tags=["question"]) app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"]) app.include_router(dashboard.router, prefix="/api/v1/dashboard", tags=["dashboard"]) +app.include_router(goal.router, prefix="/api/v1/goal", tags=["goal"]) app.include_router(co_writer.router, prefix="/api/v1/co_writer", tags=["co_writer"]) app.include_router(notebook.router, prefix="/api/v1/notebook", tags=["notebook"]) app.include_router(guide.router, prefix="/api/v1/guide", tags=["guide"]) diff --git a/deeptutor/api/routers/goal.py b/deeptutor/api/routers/goal.py new file mode 100644 index 000000000..b4a17bf7e --- /dev/null +++ b/deeptutor/api/routers/goal.py @@ -0,0 +1,306 @@ +"""Goal mode API router.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, Field + +from deeptutor.agents.goal.models import Feedback, GoalConfig +from deeptutor.agents.goal.orchestrator import GoalOrchestrator + +router = APIRouter() +_orchestrator: GoalOrchestrator | None = None + + +def get_orchestrator() -> GoalOrchestrator: + global _orchestrator + if _orchestrator is None: + _orchestrator = GoalOrchestrator() + return _orchestrator + + +def ok(data: dict) -> dict: + return {"ok": True, "data": data} + + +def api_error(code: str, message: str, detail: dict | None = None) -> HTTPException: + return HTTPException( + status_code=400, + detail={"ok": False, "error": {"code": code, "message": message, "detail": detail or {}}}, + ) + + +class CreateSessionRequest(BaseModel): + kb_name: str + goal_config: GoalConfig + + +class SubmitFeedbackRequest(BaseModel): + feedback_id: str = Field(default="fb_001") + task_id: str + completion: str + actual_minutes: int | None = None + quiz: dict | None = None + reflection: str = "" + + +class ReplanRequest(BaseModel): + reason: str | None = None + strategy: str = "rule_based" + + +class GeneratePracticeRequest(BaseModel): + count: int = 3 + difficulty: str = "medium" + question_type: str = "choice" + +class GenerateInteractivePageRequest(BaseModel): + force: bool = False + +class InteractiveChatRequest(BaseModel): + question: str + + +class ExamAnalysisResponse(BaseModel): + session_id: str + analysis: dict + artifact_path: str + + +@router.post("/create_session") +async def create_session(request: CreateSessionRequest): + try: + session = await get_orchestrator().create_session(request.kb_name, request.goal_config) + except ValueError as exc: + raise api_error("INVALID_GOAL_CONFIG", str(exc)) from exc + return ok({"session_id": session.session_id, "status": session.status}) + + +@router.get("/session/{session_id}") +async def get_session(session_id: str): + try: + session = get_orchestrator().storage.load_session(session_id) + except FileNotFoundError as exc: + raise api_error("SESSION_NOT_FOUND", f"Session not found: {session_id}") from exc + return ok( + { + "session_id": session.session_id, + "status": session.status, + "plan_version": session.plan_version, + } + ) + + +@router.get("/session/{session_id}/plan") +async def get_plan(session_id: str): + try: + plan = get_orchestrator().storage.load_plan(session_id) + except FileNotFoundError as exc: + raise api_error("PLAN_NOT_READY", f"Plan not found for session: {session_id}") from exc + return ok(plan.model_dump(mode="json", by_alias=True)) + + +@router.get("/session/{session_id}/day/{day_index}") +async def get_day_plan_detail(session_id: str, day_index: int): + orchestrator = get_orchestrator() + try: + orchestrator.storage.load_session(session_id) + except FileNotFoundError as exc: + raise api_error("SESSION_NOT_FOUND", f"Session not found: {session_id}") from exc + + try: + detail = orchestrator.get_day_plan_detail(session_id, day_index) + except FileNotFoundError as exc: + raise api_error("PLAN_NOT_READY", f"Plan not found for session: {session_id}") from exc + except ValueError as exc: + raise api_error("DAY_NOT_FOUND", str(exc)) from exc + return ok(detail.model_dump(mode="json")) + + +@router.post("/session/{session_id}/run_plan") +async def run_plan(session_id: str): + try: + plan = await get_orchestrator().run_plan(session_id) + except ValueError as exc: + raise api_error("PLAN_FAILED", str(exc)) from exc + return ok({"plan_id": plan.plan_id, "plan_version": plan.plan_version, "status": plan.status}) + + +@router.post("/session/{session_id}/feedback") +async def submit_feedback(session_id: str, request: SubmitFeedbackRequest): + try: + feedback = Feedback.model_validate( + { + "feedback_id": request.feedback_id, + "session_id": session_id, + "task_id": request.task_id, + "completion": request.completion, + "actual_minutes": request.actual_minutes, + "quiz": request.quiz, + "reflection": request.reflection, + "timestamp": __import__("datetime").datetime.now().astimezone().isoformat(), + } + ) + result = await get_orchestrator().submit_feedback(session_id, feedback) + except ValueError as exc: + raise api_error("INVALID_GOAL_CONFIG", str(exc)) from exc + return ok(result) + + +@router.post("/session/{session_id}/replan") +async def replan(session_id: str, request: ReplanRequest): + try: + plan = await get_orchestrator().replan(session_id, request.reason) + except ValueError as exc: + raise api_error("REPLAN_FAILED", str(exc)) from exc + diff = plan.diff.model_dump(mode="json") if plan.diff else {"added_tasks": [], "moved_tasks": [], "dropped_tasks": []} + return ok({"plan_version": plan.plan_version, "diff": diff}) + + +@router.post("/session/{session_id}/task/{task_id}/generate_practice") +async def generate_practice(session_id: str, task_id: str, request: GeneratePracticeRequest): + try: + result = await get_orchestrator().generate_practice(session_id, task_id, count=request.count) + except ValueError as exc: + raise api_error("TASK_NOT_FOUND", str(exc)) from exc + return ok(result) + + +@router.post("/session/{session_id}/day/{day_index}/interactive_page") +async def generate_interactive_page( + session_id: str, + day_index: int, + request: GenerateInteractivePageRequest, +): + orchestrator = get_orchestrator() + try: + orchestrator.storage.load_session(session_id) + except FileNotFoundError as exc: + raise api_error("SESSION_NOT_FOUND", f"Session not found: {session_id}") from exc + try: + result = await orchestrator.generate_day_interactive_page( + session_id=session_id, + day_index=day_index, + force=request.force, + ) + except FileNotFoundError as exc: + raise api_error("PLAN_NOT_READY", f"Plan not found for session: {session_id}") from exc + except ValueError as exc: + raise api_error("INTERACTIVE_PAGE_FAILED", str(exc)) from exc + return ok(result) + + +@router.post("/session/{session_id}/day/{day_index}/interactive_chat") +async def interactive_chat( + session_id: str, + day_index: int, + request: InteractiveChatRequest, +): + orchestrator = get_orchestrator() + try: + orchestrator.storage.load_session(session_id) + except FileNotFoundError as exc: + raise api_error("SESSION_NOT_FOUND", f"Session not found: {session_id}") from exc + try: + result = await orchestrator.answer_day_interactive_question( + session_id=session_id, + day_index=day_index, + question=request.question, + ) + except FileNotFoundError as exc: + raise api_error("PLAN_NOT_READY", f"Plan not found for session: {session_id}") from exc + except ValueError as exc: + raise api_error("INTERACTIVE_CHAT_FAILED", str(exc)) from exc + return ok(result) + + +@router.post("/session/{session_id}/exam_analysis") +async def analyze_exam_materials( + session_id: str, + pasted_text: str = Form(default=""), + files: list[UploadFile] = File(default_factory=list), +): + orchestrator = get_orchestrator() + try: + orchestrator.storage.load_session(session_id) + except FileNotFoundError as exc: + raise api_error("SESSION_NOT_FOUND", f"Session not found: {session_id}") from exc + + uploads: list[dict] = [] + for item in files: + data = await item.read() + if not data: + continue + uploads.append( + { + "name": item.filename or "upload", + "content_type": item.content_type or "application/octet-stream", + "data": data, + } + ) + + if not pasted_text.strip() and not uploads: + raise api_error("EMPTY_EXAM_INPUT", "Please provide pasted text or upload exam materials.") + + try: + result = await orchestrator.analyze_exam_materials( + session_id=session_id, + pasted_text=pasted_text, + uploads=uploads, + ) + except ValueError as exc: + raise api_error("EXAM_ANALYSIS_FAILED", str(exc)) from exc + return ok(result) + + +@router.websocket("/ws/{session_id}") +async def goal_ws(websocket: WebSocket, session_id: str): + await websocket.accept() + orchestrator = get_orchestrator() + + async def emit_events() -> None: + event_path = orchestrator.storage.get_session_dir(session_id) / "events.jsonl" + if not event_path.exists(): + return + for line in event_path.read_text(encoding="utf-8").splitlines(): + if line.strip(): + await websocket.send_json(json.loads(line)) + + try: + while True: + message = await websocket.receive_json() + msg_type = message.get("type") + + if msg_type == "run_plan": + await orchestrator.run_plan(session_id) + await emit_events() + plan = orchestrator.storage.load_plan(session_id) + await websocket.send_json({"type": "plan", "data": plan.model_dump(mode="json", by_alias=True)}) + await websocket.send_json({"type": "complete"}) + continue + + if msg_type == "get_plan": + plan = orchestrator.storage.load_plan(session_id) + await websocket.send_json({"type": "plan", "data": plan.model_dump(mode="json", by_alias=True)}) + continue + + if msg_type == "submit_feedback": + feedback = Feedback.model_validate(message.get("feedback", {})) + await orchestrator.submit_feedback(session_id, feedback) + await websocket.send_json({"type": "accepted", "task_id": feedback.task_id}) + continue + + if msg_type == "replan": + plan = await orchestrator.replan(session_id, message.get("reason")) + await websocket.send_json({"type": "plan", "data": plan.model_dump(mode="json", by_alias=True)}) + await websocket.send_json({"type": "complete"}) + continue + + await websocket.send_json({"type": "error", "code": "UNKNOWN_MESSAGE", "content": str(msg_type)}) + except WebSocketDisconnect: + return + except Exception as exc: + await websocket.send_json({"type": "error", "code": "PLAN_FAILED", "content": str(exc)}) diff --git a/deeptutor/api/routers/guide.py b/deeptutor/api/routers/guide.py index 39b748db7..b847a0b46 100644 --- a/deeptutor/api/routers/guide.py +++ b/deeptutor/api/routers/guide.py @@ -14,12 +14,14 @@ from deeptutor.api.utils.task_id_manager import TaskIDManager from deeptutor.logging import get_logger from deeptutor.services.config import PROJECT_ROOT, load_config_with_main +from deeptutor.services.config import parse_language from deeptutor.services.llm import get_llm_config from deeptutor.services.notebook import notebook_manager from deeptutor.services.settings.interface_settings import get_ui_language router = APIRouter() _guide_manager: GuideManager | None = None +_guide_manager_language: str | None = None # Initialize logger with config config = load_config_with_main("main.yaml", PROJECT_ROOT) @@ -37,6 +39,7 @@ class CreateSessionRequest(BaseModel): notebook_id: str | None = None # Optional, single notebook mode records: list[dict] | None = None # Optional, cross-notebook mode with direct records notebook_references: list[dict] | None = None + language: str | None = None class ChatRequest(BaseModel): @@ -77,10 +80,15 @@ class RetryPageRequest(BaseModel): # === Helper Functions === -def get_guide_manager(): +def get_guide_manager(language: str | None = None): """Get GuideManager instance""" - global _guide_manager - if _guide_manager is not None: + global _guide_manager, _guide_manager_language + requested_language = parse_language(language) if language else None + + if ( + _guide_manager is not None + and (requested_language is None or requested_language == _guide_manager_language) + ): return _guide_manager try: @@ -92,7 +100,10 @@ def get_guide_manager(): except Exception as e: raise HTTPException(status_code=500, detail=f"LLM config error: {e!s}") - ui_language = get_ui_language(default=config.get("system", {}).get("language", "en")) + ui_language = requested_language or get_ui_language( + default=config.get("system", {}).get("language", "zh") + ) + ui_language = parse_language(ui_language) _guide_manager = GuideManager( api_key=api_key, base_url=base_url, @@ -100,6 +111,7 @@ def get_guide_manager(): language=ui_language, binding=binding, ) # Read from config file + _guide_manager_language = ui_language return _guide_manager @@ -149,10 +161,13 @@ async def create_session(request: CreateSessionRequest): raw_user_input = user_input notebook_context = "" + requested_language = parse_language(request.language) if request.language else None if request.notebook_references: selected_records = notebook_manager.get_records_by_references(request.notebook_references) if selected_records: - analysis_agent = NotebookAnalysisAgent(language=get_ui_language(default="en")) + analysis_agent = NotebookAnalysisAgent( + language=requested_language or get_ui_language(default="zh") + ) notebook_context = await analysis_agent.analyze( user_question=raw_user_input, records=selected_records, @@ -165,7 +180,7 @@ async def create_session(request: CreateSessionRequest): # Reset LLM stats for new session BaseAgent.reset_stats("guide") - manager = get_guide_manager() + manager = get_guide_manager(requested_language) result = await manager.create_session( user_input=user_input, display_title=raw_user_input, diff --git a/deeptutor/api/routers/settings.py b/deeptutor/api/routers/settings.py index 23a26ff7c..73e835c86 100644 --- a/deeptutor/api/routers/settings.py +++ b/deeptutor/api/routers/settings.py @@ -96,32 +96,9 @@ def save_ui_settings(settings: dict[str, Any]) -> None: json.dump(settings, handle, ensure_ascii=False, indent=2) -def _provider_choices() -> dict[str, list[dict[str, str]]]: - """Build dropdown options for provider selection, keyed by service type.""" - from deeptutor.services.provider_registry import PROVIDERS - - llm = sorted( - [{"value": s.name, "label": s.label, "base_url": s.default_api_base} for s in PROVIDERS], - key=lambda p: p["label"].lower(), - ) - search = [ - {"value": "brave", "label": "Brave", "base_url": ""}, - {"value": "tavily", "label": "Tavily", "base_url": ""}, - {"value": "jina", "label": "Jina", "base_url": ""}, - {"value": "searxng", "label": "SearXNG", "base_url": ""}, - {"value": "duckduckgo", "label": "DuckDuckGo", "base_url": ""}, - {"value": "perplexity", "label": "Perplexity", "base_url": ""}, - ] - return {"llm": llm, "embedding": llm, "search": search} - - @router.get("") async def get_settings(): - return { - "ui": load_ui_settings(), - "catalog": get_model_catalog_service().load(), - "providers": _provider_choices(), - } + return {"ui": load_ui_settings(), "catalog": get_model_catalog_service().load()} @router.get("/catalog") diff --git a/deeptutor/api/run_server.py b/deeptutor/api/run_server.py index 162226ec2..e867d015b 100644 --- a/deeptutor/api/run_server.py +++ b/deeptutor/api/run_server.py @@ -4,17 +4,10 @@ Uses Python API instead of command line to avoid Windows path parsing issues. """ -import asyncio import os from pathlib import Path import sys -# Windows: uvicorn defaults to SelectorEventLoop which does not support -# asyncio.create_subprocess_exec. Switch to ProactorEventLoop so that -# child-process APIs (used by Math Animator renderer, etc.) work correctly. -if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - import uvicorn # Force unbuffered output @@ -64,7 +57,6 @@ def main() -> None: reload=True, reload_excludes=reload_excludes, log_level="info", - access_log=False, ) diff --git a/deeptutor/capabilities/request_contracts.py b/deeptutor/capabilities/request_contracts.py index 5e95c3193..bebd6bdad 100644 --- a/deeptutor/capabilities/request_contracts.py +++ b/deeptutor/capabilities/request_contracts.py @@ -41,12 +41,6 @@ class DeepQuestionRequestConfig(BaseModel): max_questions: int = Field(default=10, ge=1, le=100) -class VisualizeRequestConfig(BaseModel): - model_config = ConfigDict(extra="forbid") - - render_mode: Literal["auto", "svg", "chartjs"] = "auto" - - def _clean_public_config(raw_config: dict[str, Any] | None) -> dict[str, Any]: if raw_config is None: return {} @@ -91,12 +85,6 @@ def validate_deep_question_request_config( return _validate_model(DeepQuestionRequestConfig, raw_config, label="deep question") -def validate_visualize_request_config( - raw_config: dict[str, Any] | None, -) -> VisualizeRequestConfig: - return _validate_model(VisualizeRequestConfig, raw_config, label="visualize") - - def build_request_schema(model_type: type[BaseModel]) -> dict[str, Any]: return model_type.model_json_schema(mode="validation") @@ -107,7 +95,6 @@ def build_request_schema(model_type: type[BaseModel]) -> dict[str, Any]: "deep_question": validate_deep_question_request_config, "deep_research": validate_research_request_config, "math_animator": validate_math_animator_request_config, - "visualize": validate_visualize_request_config, } CAPABILITY_REQUEST_SCHEMAS: dict[str, dict[str, Any]] = { @@ -116,7 +103,6 @@ def build_request_schema(model_type: type[BaseModel]) -> dict[str, Any]: "deep_question": build_request_schema(DeepQuestionRequestConfig), "deep_research": build_request_schema(DeepResearchRequestConfig), "math_animator": build_request_schema(MathAnimatorRequestConfig), - "visualize": build_request_schema(VisualizeRequestConfig), } @@ -140,12 +126,10 @@ def get_capability_request_schema(capability: str) -> dict[str, Any]: "ChatRequestConfig", "DeepQuestionRequestConfig", "DeepSolveRequestConfig", - "VisualizeRequestConfig", "build_request_schema", "get_capability_request_schema", "validate_capability_config", "validate_chat_request_config", "validate_deep_question_request_config", "validate_deep_solve_request_config", - "validate_visualize_request_config", ] diff --git a/deeptutor/capabilities/visualize.py b/deeptutor/capabilities/visualize.py deleted file mode 100644 index 9835f33ac..000000000 --- a/deeptutor/capabilities/visualize.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -Visualize Capability -==================== - -Three-stage visualization pipeline: Analyze -> Generate -> Review. -Produces SVG or Chart.js code from user requests and conversation context. -""" - -from __future__ import annotations - -from typing import Any - -from deeptutor.capabilities.request_contracts import get_capability_request_schema -from deeptutor.core.capability_protocol import BaseCapability, CapabilityManifest -from deeptutor.core.context import UnifiedContext -from deeptutor.core.stream_bus import StreamBus -from deeptutor.core.trace import merge_trace_metadata - - -class VisualizeCapability(BaseCapability): - manifest = CapabilityManifest( - name="visualize", - description="Generate SVG or Chart.js visualizations.", - stages=["analyzing", "generating", "reviewing"], - tools_used=[], - cli_aliases=["visualize", "viz"], - request_schema=get_capability_request_schema("visualize"), - ) - - async def run(self, context: UnifiedContext, stream: StreamBus) -> None: - from deeptutor.agents.visualize.pipeline import VisualizePipeline - from deeptutor.services.llm.config import get_llm_config - - llm_config = get_llm_config() - history_context = str( - context.metadata.get("conversation_context_text", "") or "" - ).strip() - render_mode = str( - context.config_overrides.get("render_mode", "auto") or "auto" - ).strip().lower() - - pipeline = VisualizePipeline( - api_key=llm_config.api_key, - base_url=llm_config.base_url, - api_version=llm_config.api_version, - language=context.language, - trace_callback=self._build_trace_bridge(stream), - ) - - # Stage 1: Analyze - async with stream.stage("analyzing", source=self.name): - await stream.thinking( - "Analyzing visualization requirements...", - source=self.name, - stage="analyzing", - ) - analysis = await pipeline.run_analysis( - user_input=context.user_message, - history_context=history_context, - render_mode=render_mode, - ) - await stream.progress( - message=f"Render type: {analysis.render_type} — {analysis.description}", - source=self.name, - stage="analyzing", - ) - - # Stage 2: Generate code - async with stream.stage("generating", source=self.name): - await stream.thinking( - "Generating visualization code...", - source=self.name, - stage="generating", - ) - code = await pipeline.run_code_generation( - user_input=context.user_message, - history_context=history_context, - analysis=analysis, - ) - await stream.progress( - message="Code generated.", - source=self.name, - stage="generating", - ) - - # Stage 3: Review & optimise - async with stream.stage("reviewing", source=self.name): - await stream.thinking( - "Reviewing and optimizing code...", - source=self.name, - stage="reviewing", - ) - review = await pipeline.run_review( - user_input=context.user_message, - analysis=analysis, - code=code, - ) - final_code = review.optimized_code - if review.changed: - await stream.progress( - message=f"Code optimized: {review.review_notes}", - source=self.name, - stage="reviewing", - ) - else: - await stream.progress( - message="Code looks good — no changes needed.", - source=self.name, - stage="reviewing", - ) - - # Emit final content as a fenced code block for the chat area - lang_tag = "svg" if analysis.render_type == "svg" else "javascript" - content_md = f"```{lang_tag}\n{final_code}\n```" - await stream.content(content_md, source=self.name, stage="reviewing") - - # Structured result for the frontend viewer - await stream.result( - { - "response": content_md, - "render_type": analysis.render_type, - "code": { - "language": lang_tag, - "content": final_code, - }, - "analysis": analysis.model_dump(), - "review": review.model_dump(), - }, - source=self.name, - ) - - def _build_trace_bridge(self, stream: StreamBus): - async def _trace_bridge(update: dict[str, Any]) -> None: - event = str(update.get("event", "") or "") - stage = str(update.get("phase") or update.get("stage") or "analyzing") - base_metadata = { - key: value - for key, value in update.items() - if key - not in {"event", "state", "response", "chunk", "result", "tool_name", "tool_args"} - } - - if event != "llm_call": - return - - state = str(update.get("state", "running")) - label = str( - base_metadata.get("label", "") or stage.replace("_", " ").title() - ) - if state == "running": - await stream.progress( - message=label, - source=self.name, - stage=stage, - metadata=merge_trace_metadata( - base_metadata, - {"trace_kind": "call_status", "call_state": "running"}, - ), - ) - return - if state == "streaming": - chunk = str(update.get("chunk", "") or "") - if chunk: - await stream.thinking( - chunk, - source=self.name, - stage=stage, - metadata=merge_trace_metadata( - base_metadata, - {"trace_kind": "llm_chunk"}, - ), - ) - return - if state == "complete": - was_streaming = update.get("streaming", False) - if not was_streaming: - response = str(update.get("response", "") or "") - if response: - await stream.thinking( - response, - source=self.name, - stage=stage, - metadata=merge_trace_metadata( - base_metadata, - {"trace_kind": "llm_output"}, - ), - ) - await stream.progress( - message=label, - source=self.name, - stage=stage, - metadata=merge_trace_metadata( - base_metadata, - {"trace_kind": "call_status", "call_state": "complete"}, - ), - ) - return - if state == "error": - await stream.error( - str(update.get("response", "") or "LLM call failed."), - source=self.name, - stage=stage, - metadata=merge_trace_metadata( - base_metadata, - {"trace_kind": "call_status", "call_state": "error"}, - ), - ) - - return _trace_bridge diff --git a/deeptutor/knowledge/initializer.py b/deeptutor/knowledge/initializer.py index d621e01a7..79f3a008a 100644 --- a/deeptutor/knowledge/initializer.py +++ b/deeptutor/knowledge/initializer.py @@ -176,20 +176,8 @@ async def process_documents( ) file_paths = [str(doc_file) for doc_file in doc_files] - def _on_progress(batch_num, total_batches): - self.progress_tracker.update( - ProgressStage.PROCESSING_DOCUMENTS, - f"Embedding batches: {batch_num}/{total_batches} complete", - current=batch_num, - total=total_batches, - ) - try: - success = await rag_service.initialize( - kb_name=self.kb_name, - file_paths=file_paths, - progress_callback=_on_progress, - ) + success = await rag_service.initialize(kb_name=self.kb_name, file_paths=file_paths) if not success: self.progress_tracker.update( ProgressStage.ERROR, diff --git a/deeptutor/runtime/bootstrap/builtin_capabilities.py b/deeptutor/runtime/bootstrap/builtin_capabilities.py index dac0174f6..c7f5d3b0e 100644 --- a/deeptutor/runtime/bootstrap/builtin_capabilities.py +++ b/deeptutor/runtime/bootstrap/builtin_capabilities.py @@ -6,5 +6,4 @@ "deep_question": "deeptutor.capabilities.deep_question:DeepQuestionCapability", "deep_research": "deeptutor.capabilities.deep_research:DeepResearchCapability", "math_animator": "deeptutor.capabilities.math_animator:MathAnimatorCapability", - "visualize": "deeptutor.capabilities.visualize:VisualizeCapability", } diff --git a/deeptutor/services/config/provider_runtime.py b/deeptutor/services/config/provider_runtime.py index 3cac671d8..bca6ea132 100644 --- a/deeptutor/services/config/provider_runtime.py +++ b/deeptutor/services/config/provider_runtime.py @@ -139,7 +139,6 @@ class ResolvedEmbeddingConfig: dimension: int = 3072 request_timeout: int = 60 batch_size: int = 10 - batch_delay: float = 0.0 @dataclass(slots=True) @@ -502,7 +501,6 @@ def resolve_embedding_runtime_config( dimension=dimension, request_timeout=60, batch_size=10, - batch_delay=0.0, ) diff --git a/deeptutor/services/config/test_runner.py b/deeptutor/services/config/test_runner.py index 164165077..2b5d8546b 100644 --- a/deeptutor/services/config/test_runner.py +++ b/deeptutor/services/config/test_runner.py @@ -13,11 +13,7 @@ from .env_store import get_env_store from .model_catalog import get_model_catalog_service -from .provider_runtime import ( - resolve_embedding_runtime_config, - resolve_llm_runtime_config, - resolve_search_runtime_config, -) +from .provider_runtime import resolve_search_runtime_config def _redact(value: str) -> str: @@ -119,11 +115,11 @@ def _run_sync(self, run: TestRun, catalog: dict[str, Any]) -> None: with temporary_env(env_values): if service == "llm": - asyncio.run(self._test_llm(run, catalog)) + asyncio.run(self._test_llm(run)) elif service == "embedding": - asyncio.run(self._test_embedding(run, model or {}, catalog)) + asyncio.run(self._test_embedding(run, model or {})) elif service == "search": - self._test_search(run, catalog) + self._test_search(run) else: raise ValueError(f"Unsupported service: {service}") if not run.cancelled and run.status == "running": @@ -133,26 +129,13 @@ def _run_sync(self, run: TestRun, catalog: dict[str, Any]) -> None: run.status = "failed" run.emit("failed", str(exc)) - async def _test_llm(self, run: TestRun, catalog: dict[str, Any]) -> None: + async def _test_llm(self, run: TestRun) -> None: from deeptutor.services.llm import clear_llm_config_cache, complete as llm_complete - from deeptutor.services.llm import get_token_limit_kwargs - from deeptutor.services.llm.config import LLMConfig + from deeptutor.services.llm import get_llm_config, get_token_limit_kwargs clear_llm_config_cache() run.emit("info", "Loading LLM config from the active catalog selection.") - resolved = resolve_llm_runtime_config(catalog=catalog) - llm_config = LLMConfig( - model=resolved.model, - api_key=resolved.api_key, - base_url=resolved.base_url, - effective_url=resolved.effective_url, - binding=resolved.binding, - provider_name=resolved.provider_name, - provider_mode=resolved.provider_mode, - api_version=resolved.api_version, - extra_headers=resolved.extra_headers, - reasoning_effort=resolved.reasoning_effort, - ) + llm_config = get_llm_config() run.emit("info", f"Resolved model `{llm_config.model}` with binding `{llm_config.binding}`.") run.emit("info", f"Request target: {llm_config.base_url}") token_kwargs = get_token_limit_kwargs(llm_config.model, max_tokens=200) @@ -172,30 +155,14 @@ async def _test_llm(self, run: TestRun, catalog: dict[str, Any]) -> None: if not snippet: raise ValueError("LLM returned an empty response.") - async def _test_embedding(self, run: TestRun, model: dict[str, Any], catalog: dict[str, Any]) -> None: - from deeptutor.services.embedding.client import EmbeddingClient - from deeptutor.services.embedding.config import EmbeddingConfig + async def _test_embedding(self, run: TestRun, model: dict[str, Any]) -> None: + from deeptutor.services.embedding import get_embedding_client, get_embedding_config run.emit("info", "Loading embedding config from the active catalog selection.") - resolved = resolve_embedding_runtime_config(catalog=catalog) - config = EmbeddingConfig( - model=resolved.model, - api_key=resolved.api_key, - base_url=resolved.base_url, - effective_url=resolved.effective_url, - binding=resolved.binding, - provider_name=resolved.provider_name, - provider_mode=resolved.provider_mode, - api_version=resolved.api_version, - extra_headers=resolved.extra_headers, - dim=resolved.dimension, - request_timeout=max(1, resolved.request_timeout), - batch_size=max(1, resolved.batch_size), - batch_delay=max(0.0, resolved.batch_delay), - ) + config = get_embedding_config() run.emit("info", f"Resolved embedding model `{config.model}` with binding `{config.binding}`.") run.emit("info", f"Request target: {config.base_url}") - client = EmbeddingClient(config) + client = get_embedding_client() vectors = await client.embed(["DeepTutor embedding smoke test"]) if not vectors or not vectors[0]: raise ValueError("Embedding service returned an empty vector.") @@ -212,10 +179,10 @@ async def _test_embedding(self, run: TestRun, model: dict[str, Any], catalog: di f"Embedding dimension mismatch. expected={expected_dimension}, actual={actual_dimension}" ) - def _test_search(self, run: TestRun, catalog: dict[str, Any]) -> None: + def _test_search(self, run: TestRun) -> None: from deeptutor.services.search import web_search - resolved = resolve_search_runtime_config(catalog=catalog) + resolved = resolve_search_runtime_config() if not resolved.requested_provider: run.status = "completed" run.emit("completed", "Search skipped because no active provider is configured.") diff --git a/deeptutor/services/embedding/adapters/openai_compatible.py b/deeptutor/services/embedding/adapters/openai_compatible.py index 40ec30f79..f4d696b40 100644 --- a/deeptutor/services/embedding/adapters/openai_compatible.py +++ b/deeptutor/services/embedding/adapters/openai_compatible.py @@ -88,9 +88,8 @@ def _extract_embeddings_from_response(data: Any) -> list[list[float]]: f"Top-level keys={keys}, expected one of: data/embeddings/result/output." ) - _MAX_RETRIES = 5 + _MAX_RETRIES = 2 _RETRY_BACKOFF = 1.0 - _RATE_LIMIT_BACKOFF = 5.0 async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse: import asyncio @@ -137,18 +136,6 @@ async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse: async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post(url, json=payload, headers=headers) - # Handle rate limiting (429) with retry - if response.status_code == 429: - retry_after = float(response.headers.get("Retry-After", 0)) - wait = max(retry_after, self._RATE_LIMIT_BACKOFF * (2 ** attempt)) - logger.warning( - f"Rate limited (429) on attempt {attempt + 1}/{1 + self._MAX_RETRIES}, " - f"retrying in {wait:.1f}s..." - ) - await asyncio.sleep(wait) - last_exc = Exception(f"HTTP 429 Too Many Requests") - continue - if response.status_code >= 400: logger.error(f"HTTP {response.status_code} response body: {response.text}") @@ -158,10 +145,10 @@ async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse: except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.PoolTimeout) as exc: last_exc = exc if attempt < self._MAX_RETRIES: - wait = self._RETRY_BACKOFF * (2 ** attempt) + wait = self._RETRY_BACKOFF * (attempt + 1) logger.warning( f"Embedding request timeout (attempt {attempt + 1}/{1 + self._MAX_RETRIES}), " - f"retrying in {wait:.1f}s..." + f"retrying in {wait:.0f}s..." ) await asyncio.sleep(wait) else: @@ -169,9 +156,6 @@ async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse: f"Embedding request failed after {1 + self._MAX_RETRIES} attempts: {exc}" ) raise - else: - if last_exc: - raise last_exc embeddings = self._extract_embeddings_from_response(data) if not embeddings: diff --git a/deeptutor/services/embedding/client.py b/deeptutor/services/embedding/client.py index 454862a03..721188e13 100644 --- a/deeptutor/services/embedding/client.py +++ b/deeptutor/services/embedding/client.py @@ -60,21 +60,15 @@ def __init__(self, config: Optional[EmbeddingConfig] = None): f"(model: {self.config.model}, dimensions: {self.config.dim})" ) - async def embed( - self, texts: List[str], progress_callback=None - ) -> List[List[float]]: + async def embed(self, texts: List[str]) -> List[List[float]]: if not texts: return [] - import asyncio - batch_size = max(1, self.config.batch_size) all_embeddings: List[List[float]] = [] - batch_delay = self.config.batch_delay try: - total_batches = (len(texts) + batch_size - 1) // batch_size - for i, start in enumerate(range(0, len(texts), batch_size)): + for start in range(0, len(texts), batch_size): batch = texts[start : start + batch_size] request = EmbeddingRequest( texts=batch, @@ -83,18 +77,6 @@ async def embed( ) response = await self.adapter.embed(request) all_embeddings.extend(response.embeddings) - - # Report progress after each batch - if progress_callback: - try: - progress_callback(i + 1, total_batches) - except Exception: - pass - - # Delay between batches to avoid rate limiting - if i < total_batches - 1 and batch_delay > 0: - await asyncio.sleep(batch_delay) - self.logger.debug( f"Generated {len(all_embeddings)} embeddings using " f"{self.config.binding} (batch_size={batch_size})" diff --git a/deeptutor/services/embedding/config.py b/deeptutor/services/embedding/config.py index 107911543..beba3bcb6 100644 --- a/deeptutor/services/embedding/config.py +++ b/deeptutor/services/embedding/config.py @@ -23,7 +23,6 @@ class EmbeddingConfig: dim: int = 3072 request_timeout: int = 60 batch_size: int = 10 - batch_delay: float = 0.0 def get_embedding_config() -> EmbeddingConfig: @@ -56,6 +55,5 @@ def get_embedding_config() -> EmbeddingConfig: dim=resolved.dimension, request_timeout=max(1, resolved.request_timeout), batch_size=max(1, resolved.batch_size), - batch_delay=max(0.0, resolved.batch_delay), ) diff --git a/deeptutor/services/llm/capabilities.py b/deeptutor/services/llm/capabilities.py index 34e61d560..f55bddb15 100644 --- a/deeptutor/services/llm/capabilities.py +++ b/deeptutor/services/llm/capabilities.py @@ -165,9 +165,6 @@ "qwq": { "has_thinking_tags": True, }, - "minimax": { - "supports_response_format": False, - }, # NOTE: supports_response_format and system_in_messages are binding-level # capabilities, NOT model-level. When using OpenRouter or other OpenAI-compatible # proxies (binding="openai"), they handle response_format translation and expect diff --git a/deeptutor/services/llm/config.py b/deeptutor/services/llm/config.py index db4bc9859..389fb6a50 100644 --- a/deeptutor/services/llm/config.py +++ b/deeptutor/services/llm/config.py @@ -278,7 +278,7 @@ def uses_max_completion_tokens(model: str) -> bool: # - gpt-4o series # - gpt-5.x and later patterns = [ - r"^o\d", # o1, o3, o4-mini, o4, and future o-series models + r"^o[13]", # o1, o3 models r"^gpt-4o", # gpt-4o models r"^gpt-[5-9]", # gpt-5.x and later r"^gpt-\d{2,}", # gpt-10+ (future proofing) diff --git a/deeptutor/services/llm/executors.py b/deeptutor/services/llm/executors.py index f2c8b9675..4add109be 100644 --- a/deeptutor/services/llm/executors.py +++ b/deeptutor/services/llm/executors.py @@ -1,16 +1,13 @@ -"""Provider-backed LLM executors (openai + anthropic SDKs, no litellm).""" +"""Provider-backed LLM executors (LiteLLM + direct OpenAI/Azure).""" from __future__ import annotations -import os -import uuid from collections.abc import AsyncGenerator +import os from typing import Any -from openai import AsyncOpenAI - from deeptutor.logging import get_logger -from deeptutor.services.llm.provider_registry import find_by_name, strip_provider_prefix +from deeptutor.services.llm.provider_registry import find_by_name, normalize_model_for_litellm from .config import get_token_limit_kwargs from .utils import extract_response_content @@ -32,7 +29,7 @@ def _build_messages( ] -def _setup_provider_env(provider_name: str, api_key: str | None, api_base: str | None) -> None: +def _setup_litellm_env(provider_name: str, api_key: str | None, api_base: str | None) -> None: spec = find_by_name(provider_name) if not spec or not api_key: return @@ -44,24 +41,15 @@ def _setup_provider_env(provider_name: str, api_key: str | None, api_base: str | os.environ.setdefault(env_name, resolved) -def _resolve_model_and_base( - provider_name: str, - model: str, - api_key: str | None, - base_url: str | None, -) -> tuple[str, str | None, str | None]: - """Resolve the actual model name, base_url, and api_key for the provider. - - Returns (resolved_model, effective_base_url, effective_api_key). - """ - spec = find_by_name(provider_name) - resolved_model = strip_provider_prefix(model, spec) if spec else model - effective_base = base_url or (spec.default_api_base if spec else None) or None - effective_key = api_key - return resolved_model, effective_base, effective_key +def litellm_available() -> bool: + try: + import litellm # noqa: F401 + except Exception: + return False + return True -async def sdk_complete( +async def litellm_complete( *, prompt: str, system_prompt: str, @@ -75,26 +63,11 @@ async def sdk_complete( reasoning_effort: str | None = None, **kwargs: object, ) -> str: - """Non-streaming completion using the openai SDK.""" - _setup_provider_env(provider_name, api_key, base_url) - resolved_model, effective_base, effective_key = _resolve_model_and_base( - provider_name, model, api_key, base_url, - ) - - default_headers: dict[str, str] = {"x-session-affinity": uuid.uuid4().hex} - if extra_headers: - default_headers.update(extra_headers) - - client = AsyncOpenAI( - api_key=effective_key or "no-key", - base_url=effective_base, - default_headers=default_headers, - max_retries=0, - ) - - max_tokens_val = int(kwargs.pop("max_tokens", 4096)) - temperature_val = float(kwargs.pop("temperature", 0.7)) + from litellm import acompletion + _setup_litellm_env(provider_name, api_key, base_url) + spec = find_by_name(provider_name) + resolved_model = normalize_model_for_litellm(model, spec) payload: dict[str, Any] = { "model": resolved_model, "messages": _build_messages( @@ -102,17 +75,24 @@ async def sdk_complete( system_prompt=system_prompt, messages=messages, ), - "temperature": temperature_val, + "max_tokens": int(kwargs.pop("max_tokens", 4096)), + "temperature": float(kwargs.pop("temperature", 0.7)), + "drop_params": True, } - - token_kwargs = get_token_limit_kwargs(resolved_model, max_tokens_val) - payload.update(token_kwargs) - + payload.update(get_token_limit_kwargs(model, int(payload["max_tokens"]))) + if api_key: + payload["api_key"] = api_key + if base_url: + payload["api_base"] = base_url + if api_version: + payload["api_version"] = api_version + if extra_headers: + payload["extra_headers"] = extra_headers if reasoning_effort: payload["reasoning_effort"] = reasoning_effort payload.update(kwargs) - response = await client.chat.completions.create(**payload) + response = await acompletion(**payload) choices = getattr(response, "choices", None) or [] if not choices: return "" @@ -122,7 +102,7 @@ async def sdk_complete( return extract_response_content(message) -async def sdk_stream( +async def litellm_stream( *, prompt: str, system_prompt: str, @@ -136,26 +116,11 @@ async def sdk_stream( reasoning_effort: str | None = None, **kwargs: object, ) -> AsyncGenerator[str, None]: - """Streaming completion using the openai SDK.""" - _setup_provider_env(provider_name, api_key, base_url) - resolved_model, effective_base, effective_key = _resolve_model_and_base( - provider_name, model, api_key, base_url, - ) - - default_headers: dict[str, str] = {"x-session-affinity": uuid.uuid4().hex} - if extra_headers: - default_headers.update(extra_headers) - - client = AsyncOpenAI( - api_key=effective_key or "no-key", - base_url=effective_base, - default_headers=default_headers, - max_retries=0, - ) - - max_tokens_val = int(kwargs.pop("max_tokens", 4096)) - temperature_val = float(kwargs.pop("temperature", 0.7)) + from litellm import acompletion + _setup_litellm_env(provider_name, api_key, base_url) + spec = find_by_name(provider_name) + resolved_model = normalize_model_for_litellm(model, spec) payload: dict[str, Any] = { "model": resolved_model, "messages": _build_messages( @@ -163,18 +128,25 @@ async def sdk_stream( system_prompt=system_prompt, messages=messages, ), - "temperature": temperature_val, + "max_tokens": int(kwargs.pop("max_tokens", 4096)), + "temperature": float(kwargs.pop("temperature", 0.7)), + "drop_params": True, "stream": True, } - - token_kwargs = get_token_limit_kwargs(resolved_model, max_tokens_val) - payload.update(token_kwargs) - + payload.update(get_token_limit_kwargs(model, int(payload["max_tokens"]))) + if api_key: + payload["api_key"] = api_key + if base_url: + payload["api_base"] = base_url + if api_version: + payload["api_version"] = api_version + if extra_headers: + payload["extra_headers"] = extra_headers if reasoning_effort: payload["reasoning_effort"] = reasoning_effort payload.update(kwargs) - stream_response = await client.chat.completions.create(**payload) + stream_response = await acompletion(**payload) async for chunk in stream_response: choices = getattr(chunk, "choices", None) or [] if not choices: @@ -185,9 +157,12 @@ async def sdk_stream( delta = choice.get("delta") if delta is None: continue + # Skip stop-chunks where content is explicitly None (avoids + # extract_response_content converting the delta repr to string). raw_content = getattr(delta, "content", None) if not isinstance(delta, dict) else delta.get("content") if raw_content is None: continue content = extract_response_content(delta) if content: yield content + diff --git a/deeptutor/services/llm/factory.py b/deeptutor/services/llm/factory.py index 8b8fd85af..b3a175ff5 100644 --- a/deeptutor/services/llm/factory.py +++ b/deeptutor/services/llm/factory.py @@ -55,8 +55,9 @@ LLMTimeoutError, ) from .executors import ( - sdk_complete, - sdk_stream, + litellm_available, + litellm_complete, + litellm_stream, ) from .utils import is_local_llm_server @@ -262,13 +263,13 @@ async def _do_complete( "openai_codex requires OAuth login in CLI. " "Run `deeptutor provider login openai-codex` first." ) - if provider_mode == "oauth": + if provider_mode == "oauth" and not litellm_available(): raise LLMConfigError( - f"{provider_name} requires OAuth session. " - "Run `deeptutor provider login ...` first." + f"{provider_name} requires litellm + OAuth session. " + "Install provider deps and run `deeptutor provider login ...`." ) - if provider_mode != "direct": - return await sdk_complete( + if provider_mode != "direct" and litellm_available(): + return await litellm_complete( prompt=prompt_value, system_prompt=system_prompt_value, provider_name=provider_name, @@ -391,14 +392,14 @@ async def stream( "openai_codex requires OAuth login in CLI. " "Run `deeptutor provider login openai-codex` first." ) - if provider_mode == "oauth": + if provider_mode == "oauth" and not litellm_available(): raise LLMConfigError( - f"{provider_name} requires OAuth session. " - "Run `deeptutor provider login ...` first." + f"{provider_name} requires litellm + OAuth session. " + "Install provider deps and run `deeptutor provider login ...`." ) - if provider_mode != "direct": - async for chunk in sdk_stream( + if provider_mode != "direct" and litellm_available(): + async for chunk in litellm_stream( prompt=prompt, system_prompt=system_prompt, provider_name=provider_name, diff --git a/deeptutor/services/memory/service.py b/deeptutor/services/memory/service.py index db133c999..224da95ad 100644 --- a/deeptutor/services/memory/service.py +++ b/deeptutor/services/memory/service.py @@ -305,7 +305,7 @@ def _profile_prompts(current: str, source: str, zh: bool) -> tuple[str, str]: "你负责维护一份用户画像文档。只保留稳定的用户身份、偏好、知识水平。" f"如果无需修改,请只返回 {_NO_CHANGE}。", "如果需要更新,请重写用户画像,可使用以下标题:\n" - "## Identity\n## Learning Style\n## Knowledge Level\n## Preferences\n\n" + "## 用户身份\n## 学习风格\n## 知识水平\n## 偏好设置\n\n" "规则:保持简短,删除过时内容,不要记录临时对话。\n\n" f"[当前画像]\n{current or '(empty)'}\n\n" f"[新增材料]\n{source}" @@ -328,7 +328,7 @@ def _summary_prompts(current: str, source: str, zh: bool) -> tuple[str, str]: "你负责维护一份学习旅程摘要。记录用户正在学什么、完成了什么、有哪些待解决的问题。" f"如果无需修改,请只返回 {_NO_CHANGE}。", "如果需要更新,请重写学习旅程摘要,可使用以下标题:\n" - "## Current Focus\n## Accomplishments\n## Open Questions\n\n" + "## 当前重点\n## 已完成事项\n## 待解决问题\n\n" "规则:保持简短,删除已完成或过时的条目。\n\n" f"[当前摘要]\n{current or '(empty)'}\n\n" f"[新增材料]\n{source}" diff --git a/deeptutor/services/path_service.py b/deeptutor/services/path_service.py index c3f7cd2b4..d2b7fae7e 100644 --- a/deeptutor/services/path_service.py +++ b/deeptutor/services/path_service.py @@ -278,6 +278,12 @@ def get_run_code_workspace_dir(self) -> Path: def get_logs_dir(self) -> Path: return self.get_user_root() / "logs" + def get_goal_root_dir(self) -> Path: + return self.get_user_root() / "goal" + + def get_goal_session_dir(self, session_id: str) -> Path: + return self.get_goal_root_dir() / session_id + def ensure_agent_dir(self, module: AgentModule) -> Path: path = self.get_agent_dir(module) path.mkdir(parents=True, exist_ok=True) @@ -308,11 +314,17 @@ def ensure_settings_dir(self) -> Path: path.mkdir(parents=True, exist_ok=True) return path + def ensure_goal_root_dir(self) -> Path: + path = self.get_goal_root_dir() + path.mkdir(parents=True, exist_ok=True) + return path + def ensure_all_directories(self) -> None: self.ensure_settings_dir() self.ensure_workspace_dir() self.ensure_memory_dir() self.ensure_notebook_dir() + self.ensure_goal_root_dir() self.get_logs_dir().mkdir(parents=True, exist_ok=True) for feature in ("co-writer", "guide"): self.get_workspace_feature_dir(feature).mkdir(parents=True, exist_ok=True) diff --git a/deeptutor/services/provider_registry.py b/deeptutor/services/provider_registry.py index ca5bcf735..11e6bf2a3 100644 --- a/deeptutor/services/provider_registry.py +++ b/deeptutor/services/provider_registry.py @@ -1,11 +1,4 @@ -"""Provider registry for DeepTutor LLM routing. - -Single source of truth for provider metadata. Adding a new provider: - 1. Add a ProviderSpec to PROVIDERS below. - Done. Env vars, config matching, status display all derive from here. - -Order matters — it controls match priority and fallback. Gateways first. -""" +"""Nanobot-style provider registry for DeepTutor LLM routing.""" from __future__ import annotations @@ -15,22 +8,14 @@ @dataclass(frozen=True) class ProviderSpec: - """Single provider metadata entry. - - Placeholders in env_extras values: - {api_key} — the user's API key - {api_base} — api_base from config, or this spec's default_api_base - """ + """Single provider metadata entry.""" name: str keywords: tuple[str, ...] env_key: str display_name: str = "" - - # Which provider implementation to use: - # "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" - backend: str = "openai_compat" - + litellm_prefix: str = "" + skip_prefixes: tuple[str, ...] = () env_extras: tuple[tuple[str, str], ...] = () is_gateway: bool = False is_local: bool = False @@ -38,8 +23,6 @@ class ProviderSpec: detect_by_base_keyword: str = "" default_api_base: str = "" strip_model_prefix: bool = False - supports_max_completion_tokens: bool = False - supports_prompt_caching: bool = False model_overrides: tuple[tuple[str, dict[str, Any]], ...] = () is_oauth: bool = False is_direct: bool = False @@ -90,18 +73,12 @@ def canonical_provider_name(name: str | None) -> str | None: return PROVIDER_ALIASES.get(key, key) -# --------------------------------------------------------------------------- -# PROVIDERS — the registry. Order = priority. -# --------------------------------------------------------------------------- - PROVIDERS: tuple[ProviderSpec, ...] = ( - # === Direct (user supplies everything, no auto-detection) =============== ProviderSpec( name="custom", keywords=(), env_key="", display_name="Custom", - backend="openai_compat", is_direct=True, ), ProviderSpec( @@ -109,28 +86,25 @@ def canonical_provider_name(name: str | None) -> str | None: keywords=("azure", "azure_openai"), env_key="", display_name="Azure OpenAI", - backend="azure_openai", is_direct=True, ), - # === Gateways (detected by api_key / api_base, route any model) ======== ProviderSpec( name="openrouter", keywords=("openrouter",), env_key="OPENROUTER_API_KEY", display_name="OpenRouter", - backend="openai_compat", + litellm_prefix="openrouter", is_gateway=True, detect_by_key_prefix="sk-or-", detect_by_base_keyword="openrouter", default_api_base="https://openrouter.ai/api/v1", - supports_prompt_caching=True, ), ProviderSpec( name="aihubmix", keywords=("aihubmix",), env_key="OPENAI_API_KEY", display_name="AiHubMix", - backend="openai_compat", + litellm_prefix="openai", is_gateway=True, detect_by_base_keyword="aihubmix", default_api_base="https://aihubmix.com/v1", @@ -141,7 +115,7 @@ def canonical_provider_name(name: str | None) -> str | None: keywords=("siliconflow",), env_key="OPENAI_API_KEY", display_name="SiliconFlow", - backend="openai_compat", + litellm_prefix="openai", is_gateway=True, detect_by_base_keyword="siliconflow", default_api_base="https://api.siliconflow.cn/v1", @@ -151,7 +125,7 @@ def canonical_provider_name(name: str | None) -> str | None: keywords=("volcengine", "volces", "ark"), env_key="OPENAI_API_KEY", display_name="VolcEngine", - backend="openai_compat", + litellm_prefix="volcengine", is_gateway=True, detect_by_base_keyword="volces", default_api_base="https://ark.cn-beijing.volces.com/api/v3", @@ -161,7 +135,7 @@ def canonical_provider_name(name: str | None) -> str | None: keywords=("volcengine-plan",), env_key="OPENAI_API_KEY", display_name="VolcEngine Coding Plan", - backend="openai_compat", + litellm_prefix="volcengine", is_gateway=True, default_api_base="https://ark.cn-beijing.volces.com/api/coding/v3", strip_model_prefix=True, @@ -171,7 +145,7 @@ def canonical_provider_name(name: str | None) -> str | None: keywords=("byteplus",), env_key="OPENAI_API_KEY", display_name="BytePlus", - backend="openai_compat", + litellm_prefix="volcengine", is_gateway=True, detect_by_base_keyword="bytepluses", default_api_base="https://ark.ap-southeast.bytepluses.com/api/v3", @@ -182,36 +156,30 @@ def canonical_provider_name(name: str | None) -> str | None: keywords=("byteplus-plan",), env_key="OPENAI_API_KEY", display_name="BytePlus Coding Plan", - backend="openai_compat", + litellm_prefix="volcengine", is_gateway=True, default_api_base="https://ark.ap-southeast.bytepluses.com/api/coding/v3", strip_model_prefix=True, ), - # === Standard providers (matched by model-name keywords) =============== ProviderSpec( name="anthropic", keywords=("anthropic", "claude"), env_key="ANTHROPIC_API_KEY", display_name="Anthropic", - backend="anthropic", default_api_base="https://api.anthropic.com/v1", - supports_prompt_caching=True, ), ProviderSpec( name="openai", keywords=("openai", "gpt"), env_key="OPENAI_API_KEY", display_name="OpenAI", - backend="openai_compat", default_api_base="https://api.openai.com/v1", - supports_max_completion_tokens=True, ), ProviderSpec( name="openai_codex", keywords=("openai_codex", "codex"), env_key="", display_name="OpenAI Codex", - backend="openai_codex", is_oauth=True, default_api_base="https://chatgpt.com/backend-api", ), @@ -220,42 +188,43 @@ def canonical_provider_name(name: str | None) -> str | None: keywords=("github_copilot", "copilot"), env_key="", display_name="GitHub Copilot", - backend="github_copilot", + litellm_prefix="github_copilot", + skip_prefixes=("github_copilot/",), is_oauth=True, - default_api_base="https://api.githubcopilot.com", - strip_model_prefix=True, ), ProviderSpec( name="deepseek", keywords=("deepseek",), env_key="DEEPSEEK_API_KEY", display_name="DeepSeek", - backend="openai_compat", - default_api_base="https://api.deepseek.com", + litellm_prefix="deepseek", + skip_prefixes=("deepseek/",), + default_api_base="https://api.deepseek.com/v1", ), ProviderSpec( name="gemini", keywords=("gemini",), env_key="GEMINI_API_KEY", display_name="Gemini", - backend="openai_compat", - default_api_base="https://generativelanguage.googleapis.com/v1beta/openai/", + litellm_prefix="gemini", + skip_prefixes=("gemini/",), ), ProviderSpec( name="zhipu", keywords=("zhipu", "glm", "zai"), env_key="ZAI_API_KEY", display_name="Zhipu AI", - backend="openai_compat", + litellm_prefix="zai", + skip_prefixes=("zhipu/", "zai/", "openrouter/", "hosted_vllm/"), env_extras=(("ZHIPUAI_API_KEY", "{api_key}"),), - default_api_base="https://open.bigmodel.cn/api/paas/v4", ), ProviderSpec( name="dashscope", keywords=("qwen", "dashscope"), env_key="DASHSCOPE_API_KEY", display_name="DashScope", - backend="openai_compat", + litellm_prefix="dashscope", + skip_prefixes=("dashscope/", "openrouter/"), default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", ), ProviderSpec( @@ -263,7 +232,9 @@ def canonical_provider_name(name: str | None) -> str | None: keywords=("moonshot", "kimi"), env_key="MOONSHOT_API_KEY", display_name="Moonshot", - backend="openai_compat", + litellm_prefix="moonshot", + skip_prefixes=("moonshot/", "openrouter/"), + env_extras=(("MOONSHOT_API_BASE", "{api_base}"),), default_api_base="https://api.moonshot.ai/v1", model_overrides=(("kimi-k2.5", {"temperature": 1.0}),), ), @@ -272,40 +243,16 @@ def canonical_provider_name(name: str | None) -> str | None: keywords=("minimax",), env_key="MINIMAX_API_KEY", display_name="MiniMax", - backend="openai_compat", + litellm_prefix="minimax", + skip_prefixes=("minimax/", "openrouter/"), default_api_base="https://api.minimax.io/v1", ), - ProviderSpec( - name="mistral", - keywords=("mistral",), - env_key="MISTRAL_API_KEY", - display_name="Mistral", - backend="openai_compat", - default_api_base="https://api.mistral.ai/v1", - ), - ProviderSpec( - name="stepfun", - keywords=("stepfun", "step"), - env_key="STEPFUN_API_KEY", - display_name="Step Fun", - backend="openai_compat", - default_api_base="https://api.stepfun.com/v1", - ), - ProviderSpec( - name="xiaomi_mimo", - keywords=("xiaomi_mimo", "mimo"), - env_key="XIAOMIMIMO_API_KEY", - display_name="Xiaomi MIMO", - backend="openai_compat", - default_api_base="https://api.xiaomimimo.com/v1", - ), - # === Local deployment ================================================== ProviderSpec( name="vllm", keywords=("vllm",), env_key="HOSTED_VLLM_API_KEY", display_name="vLLM", - backend="openai_compat", + litellm_prefix="hosted_vllm", is_local=True, default_api_base="http://localhost:8000/v1", ), @@ -314,38 +261,21 @@ def canonical_provider_name(name: str | None) -> str | None: keywords=("ollama", "nemotron"), env_key="OLLAMA_API_KEY", display_name="Ollama", - backend="openai_compat", + litellm_prefix="ollama_chat", + skip_prefixes=("ollama/", "ollama_chat/"), is_local=True, detect_by_base_keyword="11434", default_api_base="http://localhost:11434/v1", ), - ProviderSpec( - name="ovms", - keywords=("openvino", "ovms"), - env_key="", - display_name="OpenVINO Model Server", - backend="openai_compat", - is_direct=True, - is_local=True, - default_api_base="http://localhost:8000/v3", - ), - # === Auxiliary ========================================================== ProviderSpec( name="groq", keywords=("groq",), env_key="GROQ_API_KEY", display_name="Groq", - backend="openai_compat", + litellm_prefix="groq", + skip_prefixes=("groq/",), default_api_base="https://api.groq.com/openai/v1", ), - ProviderSpec( - name="qianfan", - keywords=("qianfan", "ernie"), - env_key="QIANFAN_API_KEY", - display_name="Qianfan", - backend="openai_compat", - default_api_base="https://qianfan.baidubce.com/v2", - ), ) @@ -399,13 +329,17 @@ def find_gateway( return None -def strip_provider_prefix(model: str, spec: ProviderSpec | None) -> str: - """Strip the provider/ prefix from a model name if applicable.""" +def normalize_model_for_litellm(model: str, spec: ProviderSpec | None) -> str: + """Apply Nanobot-style model prefixing rules for LiteLLM.""" if not model or not spec: return model - if spec.strip_model_prefix and "/" in model: - return model.split("/", 1)[1] - return model + resolved = model + if spec.strip_model_prefix and "/" in resolved: + resolved = resolved.split("/", 1)[1] + if spec.litellm_prefix and not any(resolved.startswith(prefix) for prefix in spec.skip_prefixes): + if not resolved.startswith(f"{spec.litellm_prefix}/"): + resolved = f"{spec.litellm_prefix}/{resolved}" + return resolved __all__ = [ @@ -417,5 +351,5 @@ def strip_provider_prefix(model: str, spec: ProviderSpec | None) -> str: "find_by_name", "find_by_model", "find_gateway", - "strip_provider_prefix", + "normalize_model_for_litellm", ] diff --git a/deeptutor/services/rag/pipelines/llamaindex.py b/deeptutor/services/rag/pipelines/llamaindex.py index 7157d1c84..fac75eded 100644 --- a/deeptutor/services/rag/pipelines/llamaindex.py +++ b/deeptutor/services/rag/pipelines/llamaindex.py @@ -42,18 +42,11 @@ class CustomEmbedding(BaseEmbedding): _client: Any = PrivateAttr() _logger: Any = PrivateAttr() - _progress_callback: Any = PrivateAttr(default=None) def __init__(self, **kwargs): - progress_cb = kwargs.pop("progress_callback", None) super().__init__(**kwargs) self._client = get_embedding_client() self._logger = get_logger("CustomEmbedding") - self._progress_callback = progress_cb - - def set_progress_callback(self, callback): - """Set progress callback fn(batch_num, total_batches).""" - self._progress_callback = callback @classmethod def class_name(cls) -> str: @@ -83,9 +76,7 @@ async def _aget_text_embedding(self, text: str) -> List[float]: async def _aget_text_embeddings(self, texts: List[str]) -> List[List[float]]: """Get embeddings for multiple texts.""" - return await self._client.embed( - texts, progress_callback=self._progress_callback - ) + return await self._client.embed(texts) def _get_query_embedding(self, query: str) -> List[float]: """Sync version - called by LlamaIndex sync API.""" @@ -97,9 +88,9 @@ def _get_text_embedding(self, text: str) -> List[float]: def _get_text_embeddings(self, texts: List[str]) -> List[List[float]]: """Sync batch version - called by LlamaIndex for bulk embedding.""" - self._logger.info(f"Embedding {len(texts)} text chunks...") + self._logger.info(f"Embedding batch of {len(texts)} texts...") result = self._run_in_new_loop(self._aget_text_embeddings(texts)) - self._logger.info(f"Embedding complete: {len(result)} vectors") + self._logger.info(f"Batch embedding complete: {len(result)} vectors") return result @@ -162,13 +153,11 @@ async def initialize(self, kb_name: str, file_paths: List[str], **kwargs) -> boo Args: kb_name: Knowledge base name file_paths: List of file paths to process - **kwargs: Additional arguments (accepts progress_callback) + **kwargs: Additional arguments Returns: True if successful """ - progress_callback = kwargs.get("progress_callback") - self.logger.info( f"Initializing KB '{kb_name}' with {len(file_paths)} files using LlamaIndex" ) @@ -233,9 +222,6 @@ async def initialize(self, kb_name: str, file_paths: List[str], **kwargs) -> boo f"(chunking + embedding)..." ) - if progress_callback and isinstance(Settings.embed_model, CustomEmbedding): - Settings.embed_model.set_progress_callback(progress_callback) - loop = asyncio.get_event_loop() index = await loop.run_in_executor( None, @@ -255,9 +241,6 @@ async def initialize(self, kb_name: str, file_paths: List[str], **kwargs) -> boo self.logger.error(traceback.format_exc()) return False - finally: - if isinstance(Settings.embed_model, CustomEmbedding): - Settings.embed_model.set_progress_callback(None) def _extract_pdf_text(self, file_path: Path) -> str: """Extract text from PDF using PyMuPDF.""" @@ -373,13 +356,11 @@ async def add_documents(self, kb_name: str, file_paths: List[str], **kwargs) -> Args: kb_name: Knowledge base name file_paths: List of file paths to add - **kwargs: Additional arguments (accepts progress_callback) + **kwargs: Additional arguments Returns: True if successful """ - progress_callback = kwargs.get("progress_callback") - self.logger.info(f"Adding {len(file_paths)} documents to KB '{kb_name}' using LlamaIndex") kb_dir = Path(self.kb_base_dir) / kb_name @@ -388,9 +369,6 @@ async def add_documents(self, kb_name: str, file_paths: List[str], **kwargs) -> try: await self._verify_embedding_connectivity() - if progress_callback and isinstance(Settings.embed_model, CustomEmbedding): - Settings.embed_model.set_progress_callback(progress_callback) - # Parse new documents with centralized file routing documents = [] classification = FileTypeRouter.classify_files(file_paths) @@ -480,9 +458,6 @@ def create_index(): self.logger.error(traceback.format_exc()) return False - finally: - if isinstance(Settings.embed_model, CustomEmbedding): - Settings.embed_model.set_progress_callback(None) async def delete(self, kb_name: str) -> bool: """ diff --git a/deeptutor/services/rag/service.py b/deeptutor/services/rag/service.py index 8c6251ae9..e06fae9f3 100644 --- a/deeptutor/services/rag/service.py +++ b/deeptutor/services/rag/service.py @@ -84,14 +84,10 @@ def _get_pipeline(self): self._pipeline = get_pipeline(self.provider, kb_base_dir=self.kb_base_dir) return self._pipeline - async def initialize( - self, kb_name: str, file_paths: List[str], **kwargs - ) -> bool: + async def initialize(self, kb_name: str, file_paths: List[str], **kwargs) -> bool: self.logger.info(f"Initializing KB '{kb_name}' with provider '{self.provider}'") pipeline = self._get_pipeline() - return await pipeline.initialize( - kb_name=kb_name, file_paths=file_paths, **kwargs - ) + return await pipeline.initialize(kb_name=kb_name, file_paths=file_paths, **kwargs) async def search( self, diff --git a/deeptutor/services/search/__init__.py b/deeptutor/services/search/__init__.py index 69c996626..9463e3b16 100644 --- a/deeptutor/services/search/__init__.py +++ b/deeptutor/services/search/__init__.py @@ -18,7 +18,7 @@ ) from .base import SEARCH_API_KEY_ENV, BaseSearchProvider -from .consolidation import PROVIDER_TEMPLATES, AnswerConsolidator +from .consolidation import CONSOLIDATION_TYPES, PROVIDER_TEMPLATES, AnswerConsolidator from .providers import ( _DEPRECATED_UNSUPPORTED, get_available_providers, @@ -84,16 +84,12 @@ def web_search( output_dir: str | None = None, verbose: bool = False, provider: str | None = None, + consolidation: str | None = None, consolidation_custom_template: str | None = None, consolidation_llm_model: str | None = None, **provider_kwargs: Any, ) -> dict[str, Any]: - """Execute web search and return DeepTutor structured response shape. - - Consolidation is automatic for providers that return raw SERP results - (``supports_answer=False``). Pass ``consolidation_llm_model`` to - upgrade from template formatting to LLM synthesis. - """ + """Execute web search and return DeepTutor structured response shape.""" config = _get_web_search_config() if not config.get("enabled", True): _logger.warning("Web search is disabled in config") @@ -142,16 +138,19 @@ def web_search( _logger.error(f"[{search_provider.name}] Search failed: {exc}") raise Exception(f"{search_provider.name} search failed: {exc}") from exc - # Auto-consolidate for providers that don't generate their own answers. - if not search_provider.supports_answer: - if consolidation_custom_template is None: - consolidation_custom_template = config.get("consolidation_template") or None - use_llm = bool(consolidation_llm_model) - llm_config = {"model": consolidation_llm_model} if consolidation_llm_model else None + # Compatibility layer: only apply optional consolidation when requested. + if consolidation is None: + consolidation = config.get("consolidation") + if consolidation_custom_template is None: + consolidation_custom_template = config.get("consolidation_template") or None + if consolidation and not search_provider.supports_answer: + llm_config = {} + if consolidation_llm_model: + llm_config["model"] = consolidation_llm_model consolidator = AnswerConsolidator( - use_llm=use_llm, + consolidation_type=consolidation, custom_template=consolidation_custom_template, - llm_config=llm_config, + llm_config=llm_config if llm_config else None, ) response = consolidator.consolidate(response) @@ -185,7 +184,9 @@ def get_current_config() -> dict[str, Any]: "providers": get_providers_info(), "supported_providers": sorted(SUPPORTED_SEARCH_PROVIDERS), "deprecated_providers": sorted(DEPRECATED_SEARCH_PROVIDERS), + "consolidation": config.get("consolidation"), "consolidation_template": config.get("consolidation_template") or None, + "consolidation_types": CONSOLIDATION_TYPES, "template_providers": list(PROVIDER_TEMPLATES.keys()), } @@ -204,6 +205,7 @@ def get_current_config() -> dict[str, Any]: "Citation", "SearchResult", "AnswerConsolidator", + "CONSOLIDATION_TYPES", "PROVIDER_TEMPLATES", "BaseSearchProvider", "SearchProvider", diff --git a/deeptutor/services/search/consolidation.py b/deeptutor/services/search/consolidation.py index 972163d0a..e916eafef 100644 --- a/deeptutor/services/search/consolidation.py +++ b/deeptutor/services/search/consolidation.py @@ -1,10 +1,9 @@ """ Answer Consolidation - Generate answers from raw search results -Strategies (chosen automatically): -- Provider-specific Jinja2 template when available (serper, jina, serper_scholar) -- Generic fallback template for all other raw-SERP providers -- Optional LLM synthesis when ``use_llm=True`` +Supports two strategies: +1. template: Fast Jinja2 template rendering +2. llm: LLM-based answer synthesis (uses project's LLM config from env vars) """ from typing import Any @@ -16,9 +15,14 @@ from .types import WebSearchResponse +# Module logger _logger = get_logger("Search.Consolidation", level="INFO") +# Available consolidation types +CONSOLIDATION_TYPES = ["none", "template", "llm"] + + # ============================================================================= # PROVIDER-SPECIFIC TEMPLATES # ============================================================================= @@ -136,13 +140,19 @@ class AnswerConsolidator: - """Consolidate raw SERP results into a formatted answer. + """ + Consolidate raw SERP results into formatted answers. + + IMPORTANT: Template consolidation only works for providers that have + specific templates defined (serper, jina, serper_scholar). - By default, uses Jinja2 templates (provider-specific when available, - generic fallback otherwise). Set ``use_llm=True`` to upgrade to - LLM-based synthesis instead. + For other providers, use: + - consolidation_type="llm" for LLM-based synthesis + - custom_template for a custom Jinja2 template """ + # Map provider names to their specific templates + # Only these providers support template consolidation PROVIDER_TEMPLATE_MAP = { "serper": "serper", "jina": "jina", @@ -151,17 +161,28 @@ class AnswerConsolidator: def __init__( self, - *, - use_llm: bool = False, + consolidation_type: str = "template", custom_template: str | None = None, llm_config: dict[str, Any] | None = None, max_results: int = 5, autoescape: bool = True, ): - self.use_llm = use_llm + """ + Initialize consolidator. + + Args: + consolidation_type: "none", "template", or "llm" + custom_template: Custom Jinja2 template string + llm_config: Optional overrides (system_prompt, max_tokens, temperature) + max_results: Maximum results to include in answer + autoescape: Whether to enable Jinja2 autoescape for security (default: True) + """ + self.consolidation_type = consolidation_type self.custom_template = custom_template self.llm_config = llm_config or {} self.max_results = max_results + # Security: autoescape defaults to True (set in function signature above). + # When True, Jinja2 auto-escapes HTML to prevent XSS. self.jinja_env = Environment(loader=BaseLoader(), autoescape=autoescape) # nosec B701 if self.custom_template is not None and autoescape: @@ -172,33 +193,71 @@ def __init__( ) def consolidate(self, response: WebSearchResponse) -> WebSearchResponse: - """Consolidate search results into an answer.""" + """ + Consolidate search results into an answer. + + Args: + response: WebSearchResponse with search_results populated + + Returns: + WebSearchResponse with answer field populated + """ + if self.consolidation_type == "none": + _logger.debug("Consolidation disabled, returning raw response") + return response + results_count = len(response.search_results) + _logger.info( + f"Consolidating {results_count} results from {response.provider} using {self.consolidation_type}" + ) - if self.use_llm: - _logger.info(f"Consolidating {results_count} results from {response.provider} via LLM") + if self.consolidation_type == "template": + response.answer = self._consolidate_with_template(response) + _logger.success(f"Template consolidation completed ({len(response.answer)} chars)") + elif self.consolidation_type == "llm": response.answer = self._consolidate_with_llm(response) _logger.success(f"LLM consolidation completed ({len(response.answer)} chars)") else: - _logger.info(f"Consolidating {results_count} results from {response.provider} via template") - response.answer = self._consolidate_with_template(response) - _logger.success(f"Template consolidation completed ({len(response.answer)} chars)") + _logger.error(f"Unknown consolidation type: {self.consolidation_type}") + raise ValueError(f"Unknown consolidation type: {self.consolidation_type}") return response - def _get_template_for_provider(self, provider: str) -> str | None: - """Return the best Jinja2 template for *provider*, or ``None``.""" + def _get_template_for_provider(self, provider: str) -> str: + """ + Get the template for a specific provider. + + Only provider-specific templates exist because each provider has + different response schemas and metadata. No universal templates. + + Args: + provider: Provider name (e.g., "serper", "jina") + + Returns: + Template string for this provider + + Raises: + ValueError: If no template exists for this provider + """ + # 1. Custom template takes highest priority if self.custom_template: _logger.debug(f"Using custom template ({len(self.custom_template)} chars)") return self.custom_template + # 2. Get provider-specific template template_key = self.PROVIDER_TEMPLATE_MAP.get(provider.lower()) if template_key and template_key in PROVIDER_TEMPLATES: _logger.debug(f"Using provider-specific template: {template_key}") return PROVIDER_TEMPLATES[template_key] - _logger.debug(f"No specific template for '{provider}', using generic fallback") - return None + # 3. No template exists for this provider - fail explicitly + available = list(PROVIDER_TEMPLATES.keys()) + _logger.error(f"No template for provider '{provider}'. Available: {available}") + raise ValueError( + f"No template consolidation available for provider '{provider}'. " + f"Template consolidation only works with: {available}. " + f"Use consolidation='llm' or provide a custom_template for other providers." + ) def _build_provider_context(self, response: WebSearchResponse) -> dict[str, Any]: """ @@ -261,17 +320,11 @@ def _build_provider_context(self, response: WebSearchResponse) -> dict[str, Any] return context def _consolidate_with_template(self, response: WebSearchResponse) -> str: - """Render results using Jinja2 template or fallback to simple formatting""" + """Render results using Jinja2 template""" _logger.debug(f"Building template context for {response.provider}") # Get template (auto-detect provider-specific if not explicitly set) template_str = self._get_template_for_provider(response.provider) - - # Fallback: if no template available, use simple result formatting - if template_str is None: - _logger.info(f"Using fallback simple formatting for {response.provider}") - return self._format_simple_results(response) - template = self.jinja_env.from_string(template_str) # Build context with provider-specific fields @@ -341,31 +394,5 @@ def _build_prompts(self, response: WebSearchResponse) -> tuple[str, str]: return system_prompt, user_prompt - def _format_simple_results(self, response: WebSearchResponse) -> str: - """ - Format search results using a simple, provider-agnostic format. - - This is used as a fallback when no provider-specific template is available. - """ - lines = [f"### Search Results for \"{response.query}\"", ""] - - for i, result in enumerate(response.search_results[: self.max_results], 1): - lines.append(f"**[{i}] {result.title}**") - if result.snippet: - lines.append(f"{result.snippet}") - if result.source: - lines.append(f"*Source: {result.source}*") - lines.append(f"🔗 [{result.url}]({result.url})") - lines.append("") - - if response.search_results: - lines.append( - f"---\n*{len(response.search_results)} results via {response.provider}*" - ) - else: - lines.append("*No results found.*") - - return "\n".join(lines) - -__all__ = ["AnswerConsolidator", "PROVIDER_TEMPLATES"] +__all__ = ["AnswerConsolidator", "CONSOLIDATION_TYPES", "PROVIDER_TEMPLATES"] diff --git a/deeptutor/services/session/context_builder.py b/deeptutor/services/session/context_builder.py index 44b411d28..415b82c07 100644 --- a/deeptutor/services/session/context_builder.py +++ b/deeptutor/services/session/context_builder.py @@ -112,14 +112,13 @@ def _build_history(self, summary: str, messages: list[dict[str, Any]]) -> list[d cleaned_summary = summary.strip() if cleaned_summary: history.append({"role": "system", "content": cleaned_summary}) - # Filter out system messages from DB since summary is already added as system history.extend( { "role": item.get("role", "user"), "content": str(item.get("content", "") or ""), } for item in messages - if item.get("role") in {"user", "assistant"} # Removed "system" to avoid duplicate system messages + if item.get("role") in {"user", "assistant", "system"} and str(item.get("content", "") or "").strip() ) return history @@ -257,21 +256,10 @@ async def _trace_bridge(update: dict[str, Any]) -> None: "user goals, constraints, decisions, unresolved questions, and any capability " "switches. Keep the summary concise and faithful. Use bullet points only if useful." ) - if language.startswith("zh"): - system_prompt = ( - "你负责把对话历史压缩成后续轮次可直接使用的上下文。保留用户目标、约束、已做决定、" - "未解决问题,以及能力切换带来的关键信息。总结要忠实、紧凑,不要虚构。" - ) user_prompt = ( f"Compress the following conversation history into <= {summary_budget} tokens.\n\n" f"{source_text}" ) - if language.startswith("zh"): - user_prompt = ( - f"请把下面的对话历史压缩到不超过 {summary_budget} tokens 的长度," - "供后续对话直接继承上下文。\n\n" - f"{source_text}" - ) try: _chunks: list[str] = [] async for _c in agent.stream_llm( diff --git a/deeptutor/services/session/turn_runtime.py b/deeptutor/services/session/turn_runtime.py index 1d2c62d2e..3715ac6a5 100644 --- a/deeptutor/services/session/turn_runtime.py +++ b/deeptutor/services/session/turn_runtime.py @@ -102,45 +102,6 @@ def _format_followup_question_context(context: dict[str, Any], language: str = " else "unknown" ) - if str(language or "en").lower().startswith("zh"): - lines = [ - "你正在处理一道测验题的后续追问。", - "下面是本题上下文,请在后续回答中优先围绕这道题进行解释、纠错、延展和追问。", - "如果用户提出超出本题的内容,也可以正常回答,但要保持和本题的连续性。", - "", - "[Question Follow-up Context]", - f"Question ID: {context.get('question_id') or '(none)'}", - f"Parent quiz session: {context.get('parent_quiz_session_id') or '(none)'}", - f"Question type: {context.get('question_type') or '(none)'}", - f"Difficulty: {context.get('difficulty') or '(none)'}", - f"Concentration: {context.get('concentration') or '(none)'}", - "", - "Question:", - context.get("question") or "(none)", - ] - if option_lines: - lines.extend(["", "Options:", *option_lines]) - lines.extend( - [ - "", - f"User answer: {context.get('user_answer') or '(not provided)'}", - f"User result: {correctness_text}", - f"Reference answer: {context.get('correct_answer') or '(none)'}", - "", - "Explanation:", - context.get("explanation") or "(none)", - ] - ) - if context.get("knowledge_context"): - lines.extend( - [ - "", - "Knowledge context:", - context["knowledge_context"], - ] - ) - return "\n".join(lines).strip() - lines = [ "You are handling follow-up questions about a single quiz item.", "Use the question context below as the primary grounding for future turns in this session.", @@ -458,13 +419,6 @@ async def _run_turn(self, execution: _TurnExecution) -> None: records=history_records, emit=lambda event: self._persist_and_publish(execution, event), ) - # Fallback: if analysis returns empty, use raw record content - if not history_context.strip(): - history_context = "\n\n".join( - f"## Session: {record.get('title', 'Untitled')}\n{record.get('output', '')}" - for record in history_records - if record.get("output") - ) effective_user_message = raw_user_content context_parts: list[str] = [] diff --git a/deeptutor/services/setup/init.py b/deeptutor/services/setup/init.py index 7feecc8ce..12c3cdc95 100644 --- a/deeptutor/services/setup/init.py +++ b/deeptutor/services/setup/init.py @@ -50,6 +50,8 @@ }, "web_search": { "enabled": True, + "consolidation": "template", + "consolidation_template": "", }, }, "capabilities": { diff --git a/deeptutor/services/tutorbot/manager.py b/deeptutor/services/tutorbot/manager.py index 5ee73fb92..3dd68ba3b 100644 --- a/deeptutor/services/tutorbot/manager.py +++ b/deeptutor/services/tutorbot/manager.py @@ -681,38 +681,38 @@ def _save_souls(self, souls: list[dict[str, str]]) -> None: def _seed_default_souls(self) -> None: defaults = [ {"id": "default-tutorbot", "name": "Default TutorBot", "content": ( - "# Soul\n\nI am TutorBot, a personal learning companion.\n\n" - "## Personality\n\n- Helpful and friendly\n- Clear, encouraging, and patient\n" - "- Adapts explanations to the user's level\n\n" - "## Values\n\n- Accuracy over speed\n- User privacy and safety\n- Transparency in actions" + "# Soul Profile\n\nYou are TutorBot, a learning companion for students.\n\n" + "## Personality\n\n- Friendly and patient\n- Clear and encouraging communication\n" + "- Adapt explanation depth to the learner's level\n\n" + "## Values\n\n- Accuracy over speed\n- Respect privacy and safety boundaries\n- Be transparent about uncertainty" )}, {"id": "math-tutor", "name": "Math Tutor", "content": ( - "# Soul\n\nI am a math tutor specializing in clear, step-by-step problem solving.\n\n" - "## Personality\n\n- Patient and methodical\n- Encourages showing work\n" - "- Celebrates progress on hard problems\n\n" - "## Teaching Style\n\n- Break complex problems into small steps\n" - "- Use visual representations when possible\n- Always verify final answers" + "# Soul Profile\n\nYou are a math tutor who breaks complex problems into executable steps.\n\n" + "## Personality\n\n- Patient, rigorous, step-by-step\n- Encourage showing derivation process\n" + "- Build confidence through structured progress\n\n" + "## Teaching Style\n\n- Explain strategy before calculation details\n" + "- Use visual intuition when needed\n- Always verify final results" )}, {"id": "coding-assistant", "name": "Coding Assistant", "content": ( - "# Soul\n\nI am a coding assistant focused on helping developers write better software.\n\n" - "## Personality\n\n- Precise and detail-oriented\n" - "- Pragmatic — working code over perfect code\n- Explains trade-offs clearly\n\n" - "## Approach\n\n- Read before writing; understand context first\n" - "- Suggest tests alongside implementations\n- Prefer standard patterns over clever tricks" + "# Soul Profile\n\nYou are a coding assistant focused on readable, testable, maintainable code.\n\n" + "## Personality\n\n- Detail-oriented and pragmatic\n" + "- Explain trade-offs when proposing solutions\n\n" + "## Working Style\n\n- Understand requirements and context before changes\n" + "- Provide test suggestions with code updates\n- Prefer stable and maintainable patterns" )}, - {"id": "research-helper", "name": "Research Helper", "content": ( - "# Soul\n\nI am a research assistant helping users explore academic topics in depth.\n\n" - "## Personality\n\n- Curious and thorough\n" - "- Balanced — presents multiple perspectives\n- Cites sources when possible\n\n" - "## Approach\n\n- Decompose broad questions into focused sub-questions\n" - "- Distinguish established facts from open questions\n- Suggest further reading" + {"id": "research-helper", "name": "Research Assistant", "content": ( + "# Soul Profile\n\nYou are a research assistant who helps users explore topics systematically.\n\n" + "## Personality\n\n- Curious, comprehensive, evidence-driven\n" + "- Provide multi-angle analysis when possible\n\n" + "## Working Style\n\n- Decompose big questions into subquestions\n" + "- Distinguish confirmed facts from open hypotheses\n- Provide next-step reading and search suggestions" )}, {"id": "language-tutor", "name": "Language Tutor", "content": ( - "# Soul\n\nI am a language learning companion helping users practice and improve.\n\n" - "## Personality\n\n- Encouraging and patient\n" - "- Adapts difficulty to learner level\n- Makes learning fun with examples\n\n" - "## Teaching Style\n\n- Correct mistakes gently with explanations\n" - "- Use contextual examples over abstract rules\n- Encourage speaking/writing practice" + "# Soul Profile\n\nYou are a language tutor who improves expression in real contexts.\n\n" + "## Personality\n\n- Supportive and patient with corrections\n" + "- Adjust difficulty dynamically to learner level\n\n" + "## Teaching Style\n\n- Give natural expressions first, then explain rules\n" + "- Use contextual examples and avoid rigid lists\n- Encourage continuous speaking/writing practice" )}, ] self._save_souls(defaults) diff --git a/deeptutor/tools/web_search.py b/deeptutor/tools/web_search.py index 5f247f214..5a14c9bed 100644 --- a/deeptutor/tools/web_search.py +++ b/deeptutor/tools/web_search.py @@ -28,6 +28,7 @@ # Re-export from services layer from deeptutor.services.search import ( + CONSOLIDATION_TYPES, PROVIDER_TEMPLATES, SEARCH_API_KEY_ENV, AnswerConsolidator, @@ -61,6 +62,7 @@ "SearchResult", # Consolidation "AnswerConsolidator", + "CONSOLIDATION_TYPES", "PROVIDER_TEMPLATES", # Base class "BaseSearchProvider", diff --git a/deeptutor/tutorbot/providers/__init__.py b/deeptutor/tutorbot/providers/__init__.py index 992096bfd..07a9ead7e 100644 --- a/deeptutor/tutorbot/providers/__init__.py +++ b/deeptutor/tutorbot/providers/__init__.py @@ -2,18 +2,11 @@ from deeptutor.tutorbot.providers.base import LLMProvider, LLMResponse -__all__ = ["LLMProvider", "LLMResponse", "OpenAICompatProvider", "AnthropicProvider"] +__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider"] def __getattr__(name: str): - if name == "OpenAICompatProvider": - from deeptutor.tutorbot.providers.openai_compat_provider import OpenAICompatProvider - return OpenAICompatProvider - if name == "AnthropicProvider": - from deeptutor.tutorbot.providers.anthropic_provider import AnthropicProvider - return AnthropicProvider - # Legacy alias if name == "LiteLLMProvider": - from deeptutor.tutorbot.providers.openai_compat_provider import OpenAICompatProvider - return OpenAICompatProvider + from deeptutor.tutorbot.providers.litellm_provider import LiteLLMProvider + return LiteLLMProvider raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/deeptutor/tutorbot/providers/anthropic_provider.py b/deeptutor/tutorbot/providers/anthropic_provider.py deleted file mode 100644 index 471838ffd..000000000 --- a/deeptutor/tutorbot/providers/anthropic_provider.py +++ /dev/null @@ -1,470 +0,0 @@ -"""Anthropic provider — direct SDK integration for Claude models. - -Handles message format conversion (OpenAI -> Anthropic Messages API), -prompt caching, extended thinking, tool calls, and streaming. -""" - -from __future__ import annotations - -import asyncio -import re -import secrets -import string -from collections.abc import Awaitable, Callable -from typing import Any - -import json_repair - -from deeptutor.tutorbot.providers.base import LLMProvider, LLMResponse, ToolCallRequest - -_ALNUM = string.ascii_letters + string.digits - - -def _gen_tool_id() -> str: - return "toolu_" + "".join(secrets.choice(_ALNUM) for _ in range(22)) - - -class AnthropicProvider(LLMProvider): - """LLM provider using the native Anthropic SDK for Claude models.""" - - def __init__( - self, - api_key: str | None = None, - api_base: str | None = None, - default_model: str = "claude-sonnet-4-20250514", - extra_headers: dict[str, str] | None = None, - ): - super().__init__(api_key, api_base) - self.default_model = default_model - self.extra_headers = extra_headers or {} - - from anthropic import AsyncAnthropic - - client_kw: dict[str, Any] = {"max_retries": 0} - if api_key: - client_kw["api_key"] = api_key - if api_base: - client_kw["base_url"] = api_base - if extra_headers: - client_kw["default_headers"] = extra_headers - self._client = AsyncAnthropic(**client_kw) - - @classmethod - def _handle_error(cls, e: Exception) -> LLMResponse: - payload = ( - getattr(e, "body", None) - or getattr(e, "doc", None) - or getattr(getattr(e, "response", None), "text", None) - ) - payload_text = payload if isinstance(payload, str) else str(payload) if payload is not None else "" - msg = f"Error: {payload_text.strip()[:500]}" if payload_text.strip() else f"Error calling LLM: {e}" - return LLMResponse(content=msg, finish_reason="error") - - @staticmethod - def _strip_prefix(model: str) -> str: - if model.startswith("anthropic/"): - return model[len("anthropic/"):] - return model - - # ------------------------------------------------------------------ - # Message conversion: OpenAI chat format -> Anthropic Messages API - # ------------------------------------------------------------------ - - def _convert_messages( - self, messages: list[dict[str, Any]], - ) -> tuple[str | list[dict[str, Any]], list[dict[str, Any]]]: - """Return ``(system, anthropic_messages)``.""" - system: str | list[dict[str, Any]] = "" - raw: list[dict[str, Any]] = [] - - for msg in messages: - role = msg.get("role", "") - content = msg.get("content") - - if role == "system": - system = content if isinstance(content, (str, list)) else str(content or "") - continue - - if role == "tool": - block = self._tool_result_block(msg) - if raw and raw[-1]["role"] == "user": - prev_c = raw[-1]["content"] - if isinstance(prev_c, list): - prev_c.append(block) - else: - raw[-1]["content"] = [ - {"type": "text", "text": prev_c or ""}, block, - ] - else: - raw.append({"role": "user", "content": [block]}) - continue - - if role == "assistant": - raw.append({"role": "assistant", "content": self._assistant_blocks(msg)}) - continue - - if role == "user": - raw.append({ - "role": "user", - "content": self._convert_user_content(content), - }) - continue - - return system, self._merge_consecutive(raw) - - @staticmethod - def _tool_result_block(msg: dict[str, Any]) -> dict[str, Any]: - content = msg.get("content") - block: dict[str, Any] = { - "type": "tool_result", - "tool_use_id": msg.get("tool_call_id", ""), - } - if isinstance(content, (str, list)): - block["content"] = content - else: - block["content"] = str(content) if content else "" - return block - - @staticmethod - def _assistant_blocks(msg: dict[str, Any]) -> list[dict[str, Any]]: - blocks: list[dict[str, Any]] = [] - content = msg.get("content") - - for tb in msg.get("thinking_blocks") or []: - if isinstance(tb, dict) and tb.get("type") == "thinking": - blocks.append({ - "type": "thinking", - "thinking": tb.get("thinking", ""), - "signature": tb.get("signature", ""), - }) - - if isinstance(content, str) and content: - blocks.append({"type": "text", "text": content}) - elif isinstance(content, list): - for item in content: - blocks.append(item if isinstance(item, dict) else {"type": "text", "text": str(item)}) - - for tc in msg.get("tool_calls") or []: - if not isinstance(tc, dict): - continue - func = tc.get("function", {}) - args = func.get("arguments", "{}") - if isinstance(args, str): - args = json_repair.loads(args) - blocks.append({ - "type": "tool_use", - "id": tc.get("id") or _gen_tool_id(), - "name": func.get("name", ""), - "input": args, - }) - - return blocks or [{"type": "text", "text": ""}] - - def _convert_user_content(self, content: Any) -> Any: - if isinstance(content, str) or content is None: - return content or "(empty)" - if not isinstance(content, list): - return str(content) - - result: list[dict[str, Any]] = [] - for item in content: - if not isinstance(item, dict): - result.append({"type": "text", "text": str(item)}) - continue - if item.get("type") == "image_url": - converted = self._convert_image_block(item) - if converted: - result.append(converted) - continue - result.append(item) - return result or "(empty)" - - @staticmethod - def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None: - url = (block.get("image_url") or {}).get("url", "") - if not url: - return None - m = re.match(r"data:(image/\w+);base64,(.+)", url, re.DOTALL) - if m: - return { - "type": "image", - "source": {"type": "base64", "media_type": m.group(1), "data": m.group(2)}, - } - return { - "type": "image", - "source": {"type": "url", "url": url}, - } - - @staticmethod - def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Anthropic requires alternating user/assistant roles.""" - merged: list[dict[str, Any]] = [] - for msg in msgs: - if merged and merged[-1]["role"] == msg["role"]: - prev_c = merged[-1]["content"] - cur_c = msg["content"] - if isinstance(prev_c, str): - prev_c = [{"type": "text", "text": prev_c}] - if isinstance(cur_c, str): - cur_c = [{"type": "text", "text": cur_c}] - if isinstance(cur_c, list): - prev_c.extend(cur_c) - merged[-1]["content"] = prev_c - else: - merged.append(msg) - return merged - - # ------------------------------------------------------------------ - # Tool definition conversion - # ------------------------------------------------------------------ - - @staticmethod - def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None: - if not tools: - return None - result = [] - for tool in tools: - func = tool.get("function", tool) - entry: dict[str, Any] = { - "name": func.get("name", ""), - "input_schema": func.get("parameters", {"type": "object", "properties": {}}), - } - desc = func.get("description") - if desc: - entry["description"] = desc - if "cache_control" in tool: - entry["cache_control"] = tool["cache_control"] - result.append(entry) - return result - - @staticmethod - def _convert_tool_choice( - tool_choice: str | dict[str, Any] | None, - thinking_enabled: bool = False, - ) -> dict[str, Any] | None: - if thinking_enabled: - return {"type": "auto"} - if tool_choice is None or tool_choice == "auto": - return {"type": "auto"} - if tool_choice == "required": - return {"type": "any"} - if tool_choice == "none": - return None - if isinstance(tool_choice, dict): - name = tool_choice.get("function", {}).get("name") - if name: - return {"type": "tool", "name": name} - return {"type": "auto"} - - # ------------------------------------------------------------------ - # Prompt caching - # ------------------------------------------------------------------ - - @classmethod - def _apply_cache_control( - cls, - system: str | list[dict[str, Any]], - messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None, - ) -> tuple[str | list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]] | None]: - marker = {"type": "ephemeral"} - - if isinstance(system, str) and system: - system = [{"type": "text", "text": system, "cache_control": marker}] - elif isinstance(system, list) and system: - system = list(system) - system[-1] = {**system[-1], "cache_control": marker} - - new_msgs = list(messages) - if len(new_msgs) >= 3: - m = new_msgs[-2] - c = m.get("content") - if isinstance(c, str): - new_msgs[-2] = {**m, "content": [{"type": "text", "text": c, "cache_control": marker}]} - elif isinstance(c, list) and c: - nc = list(c) - nc[-1] = {**nc[-1], "cache_control": marker} - new_msgs[-2] = {**m, "content": nc} - - new_tools = tools - if tools: - new_tools = list(tools) - for idx in cls._tool_cache_marker_indices(new_tools): - new_tools[idx] = {**new_tools[idx], "cache_control": marker} - - return system, new_msgs, new_tools - - # ------------------------------------------------------------------ - # Build API kwargs - # ------------------------------------------------------------------ - - def _build_kwargs( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None, - model: str | None, - max_tokens: int, - temperature: float, - reasoning_effort: str | None, - tool_choice: str | dict[str, Any] | None, - ) -> dict[str, Any]: - model_name = self._strip_prefix(model or self.default_model) - system, anthropic_msgs = self._convert_messages(self._sanitize_empty_content(messages)) - anthropic_tools = self._convert_tools(tools) - - system, anthropic_msgs, anthropic_tools = self._apply_cache_control( - system, anthropic_msgs, anthropic_tools, - ) - - max_tokens = max(1, max_tokens) - thinking_enabled = bool(reasoning_effort) - - kwargs: dict[str, Any] = { - "model": model_name, - "messages": anthropic_msgs, - "max_tokens": max_tokens, - } - - if system: - kwargs["system"] = system - - if reasoning_effort == "adaptive": - kwargs["thinking"] = {"type": "adaptive"} - kwargs["temperature"] = 1.0 - elif thinking_enabled: - budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)} - budget = budget_map.get(reasoning_effort.lower(), 4096) - kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget} - kwargs["max_tokens"] = max(max_tokens, budget + 4096) - kwargs["temperature"] = 1.0 - else: - kwargs["temperature"] = temperature - - if anthropic_tools: - kwargs["tools"] = anthropic_tools - tc = self._convert_tool_choice(tool_choice, thinking_enabled) - if tc: - kwargs["tool_choice"] = tc - - if self.extra_headers: - kwargs["extra_headers"] = self.extra_headers - - return kwargs - - # ------------------------------------------------------------------ - # Response parsing - # ------------------------------------------------------------------ - - @staticmethod - def _parse_response(response: Any) -> LLMResponse: - content_parts: list[str] = [] - tool_calls: list[ToolCallRequest] = [] - thinking_blocks: list[dict[str, Any]] = [] - - for block in response.content: - if block.type == "text": - content_parts.append(block.text) - elif block.type == "tool_use": - tool_calls.append(ToolCallRequest( - id=block.id, - name=block.name, - arguments=block.input if isinstance(block.input, dict) else {}, - )) - elif block.type == "thinking": - thinking_blocks.append({ - "type": "thinking", - "thinking": block.thinking, - "signature": getattr(block, "signature", ""), - }) - - stop_map = {"tool_use": "tool_calls", "end_turn": "stop", "max_tokens": "length"} - finish_reason = stop_map.get(response.stop_reason or "", response.stop_reason or "stop") - - usage: dict[str, int] = {} - if response.usage: - input_tokens = response.usage.input_tokens - cache_creation = getattr(response.usage, "cache_creation_input_tokens", 0) or 0 - cache_read = getattr(response.usage, "cache_read_input_tokens", 0) or 0 - total_prompt = input_tokens + cache_creation + cache_read - usage = { - "prompt_tokens": total_prompt, - "completion_tokens": response.usage.output_tokens, - "total_tokens": total_prompt + response.usage.output_tokens, - } - - return LLMResponse( - content="".join(content_parts) or None, - tool_calls=tool_calls, - finish_reason=finish_reason, - usage=usage, - thinking_blocks=thinking_blocks or None, - ) - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - async def chat( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None = None, - model: str | None = None, - max_tokens: int = 4096, - temperature: float = 0.7, - reasoning_effort: str | None = None, - tool_choice: str | dict[str, Any] | None = None, - ) -> LLMResponse: - kwargs = self._build_kwargs( - messages, tools, model, max_tokens, temperature, - reasoning_effort, tool_choice, - ) - try: - response = await self._client.messages.create(**kwargs) - return self._parse_response(response) - except Exception as e: - return self._handle_error(e) - - async def chat_stream( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None = None, - model: str | None = None, - max_tokens: int = 4096, - temperature: float = 0.7, - reasoning_effort: str | None = None, - tool_choice: str | dict[str, Any] | None = None, - on_content_delta: Callable[[str], Awaitable[None]] | None = None, - ) -> LLMResponse: - kwargs = self._build_kwargs( - messages, tools, model, max_tokens, temperature, - reasoning_effort, tool_choice, - ) - idle_timeout_s = 90 - try: - async with self._client.messages.stream(**kwargs) as stream: - if on_content_delta: - stream_iter = stream.text_stream.__aiter__() - while True: - try: - text = await asyncio.wait_for( - stream_iter.__anext__(), - timeout=idle_timeout_s, - ) - except StopAsyncIteration: - break - await on_content_delta(text) - response = await asyncio.wait_for( - stream.get_final_message(), - timeout=idle_timeout_s, - ) - return self._parse_response(response) - except asyncio.TimeoutError: - return LLMResponse( - content=f"Error calling LLM: stream stalled for more than {idle_timeout_s} seconds", - finish_reason="error", - ) - except Exception as e: - return self._handle_error(e) - - def get_default_model(self) -> str: - return self.default_model diff --git a/deeptutor/tutorbot/providers/base.py b/deeptutor/tutorbot/providers/base.py index 905b68d6d..114a94861 100644 --- a/deeptutor/tutorbot/providers/base.py +++ b/deeptutor/tutorbot/providers/base.py @@ -143,20 +143,6 @@ def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, An result.append(msg) return result - @staticmethod - def _tool_cache_marker_indices(tools: list[dict[str, Any]]) -> list[int]: - """Return indices of tool definitions that should get cache_control markers. - - Heuristic: mark the last tool, and every 5th tool counting backwards, - to balance cache granularity vs. overhead. - """ - n = len(tools) - if n == 0: - return [] - if n <= 5: - return [n - 1] - return [i for i in range(n - 1, -1, -5)] - @staticmethod def _sanitize_request_messages( messages: list[dict[str, Any]], diff --git a/deeptutor/tutorbot/providers/deeptutor_adapter.py b/deeptutor/tutorbot/providers/deeptutor_adapter.py index 009852b5f..bbebf2d75 100644 --- a/deeptutor/tutorbot/providers/deeptutor_adapter.py +++ b/deeptutor/tutorbot/providers/deeptutor_adapter.py @@ -2,50 +2,23 @@ When TutorBot runs in-process inside the DeepTutor server, this provider reads api_key / model / base_url from DeepTutor's unified config and -delegates to the appropriate provider (OpenAICompat or Anthropic). +delegates to the standard LiteLLMProvider, avoiding duplicate configuration. """ from __future__ import annotations -from deeptutor.tutorbot.providers.base import LLMProvider +from deeptutor.tutorbot.providers.litellm_provider import LiteLLMProvider -def create_deeptutor_provider() -> LLMProvider: - """Build a provider pre-configured from DeepTutor's LLMConfig.""" +def create_deeptutor_provider() -> LiteLLMProvider: + """Build a LiteLLMProvider pre-configured from DeepTutor's LLMConfig.""" from deeptutor.services.llm.config import get_llm_config - from deeptutor.services.provider_registry import find_by_model, find_by_name cfg = get_llm_config() - - api_key = cfg.api_key or None - api_base = cfg.effective_url or cfg.base_url or None - model = cfg.model - extra_headers = cfg.extra_headers or {} - provider_name = cfg.provider_name or None - - spec = None - if provider_name: - spec = find_by_name(provider_name) - if spec is None and model: - spec = find_by_model(model) - - backend = spec.backend if spec else "openai_compat" - - if backend == "anthropic": - from deeptutor.tutorbot.providers.anthropic_provider import AnthropicProvider - return AnthropicProvider( - api_key=api_key, - api_base=api_base, - default_model=model, - extra_headers=extra_headers, - ) - - from deeptutor.tutorbot.providers.openai_compat_provider import OpenAICompatProvider - return OpenAICompatProvider( - api_key=api_key, - api_base=api_base, - default_model=model, - extra_headers=extra_headers, - spec=spec, - provider_name=provider_name, + return LiteLLMProvider( + api_key=cfg.api_key or None, + api_base=cfg.effective_url or cfg.base_url or None, + default_model=cfg.model, + extra_headers=cfg.extra_headers or {}, + provider_name=cfg.provider_name or None, ) diff --git a/deeptutor/tutorbot/providers/litellm_provider.py b/deeptutor/tutorbot/providers/litellm_provider.py new file mode 100644 index 000000000..64ee6e9b1 --- /dev/null +++ b/deeptutor/tutorbot/providers/litellm_provider.py @@ -0,0 +1,353 @@ +"""LiteLLM provider implementation for multi-provider support.""" + +import hashlib +import os +import secrets +import string +from typing import Any + +import json_repair +import litellm +from litellm import acompletion +from loguru import logger + +from deeptutor.tutorbot.providers.base import LLMProvider, LLMResponse, ToolCallRequest +from deeptutor.tutorbot.providers.registry import find_by_model, find_gateway + +# Standard chat-completion message keys. +_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}) +_ANTHROPIC_EXTRA_KEYS = frozenset({"thinking_blocks"}) +_ALNUM = string.ascii_letters + string.digits + +def _short_tool_id() -> str: + """Generate a 9-char alphanumeric ID compatible with all providers (incl. Mistral).""" + return "".join(secrets.choice(_ALNUM) for _ in range(9)) + + +class LiteLLMProvider(LLMProvider): + """ + LLM provider using LiteLLM for multi-provider support. + + Supports OpenRouter, Anthropic, OpenAI, Gemini, MiniMax, and many other providers through + a unified interface. Provider-specific logic is driven by the registry + (see providers/registry.py) — no if-elif chains needed here. + """ + + def __init__( + self, + api_key: str | None = None, + api_base: str | None = None, + default_model: str = "anthropic/claude-opus-4-5", + extra_headers: dict[str, str] | None = None, + provider_name: str | None = None, + ): + super().__init__(api_key, api_base) + self.default_model = default_model + self.extra_headers = extra_headers or {} + + # Detect gateway / local deployment. + # provider_name (from config key) is the primary signal; + # api_key / api_base are fallback for auto-detection. + self._gateway = find_gateway(provider_name, api_key, api_base) + + # Configure environment variables + if api_key: + self._setup_env(api_key, api_base, default_model) + + if api_base: + litellm.api_base = api_base + + # Disable LiteLLM logging noise + litellm.suppress_debug_info = True + # Drop unsupported parameters for providers (e.g., gpt-5 rejects some params) + litellm.drop_params = True + + self._langsmith_enabled = bool(os.getenv("LANGSMITH_API_KEY")) + + def _setup_env(self, api_key: str, api_base: str | None, model: str) -> None: + """Set environment variables based on detected provider.""" + spec = self._gateway or find_by_model(model) + if not spec: + return + if not spec.env_key: + # OAuth/provider-only specs (for example: openai_codex) + return + + # Gateway/local overrides existing env; standard provider doesn't + if self._gateway: + os.environ[spec.env_key] = api_key + else: + os.environ.setdefault(spec.env_key, api_key) + + # Resolve env_extras placeholders: + # {api_key} → user's API key + # {api_base} → user's api_base, falling back to spec.default_api_base + effective_base = api_base or spec.default_api_base + for env_name, env_val in spec.env_extras: + resolved = env_val.replace("{api_key}", api_key) + resolved = resolved.replace("{api_base}", effective_base) + os.environ.setdefault(env_name, resolved) + + def _resolve_model(self, model: str) -> str: + """Resolve model name by applying provider/gateway prefixes.""" + if self._gateway: + # Gateway mode: apply gateway prefix, skip provider-specific prefixes + prefix = self._gateway.litellm_prefix + if self._gateway.strip_model_prefix: + model = model.split("/")[-1] + if prefix and not model.startswith(f"{prefix}/"): + model = f"{prefix}/{model}" + return model + + # Standard mode: auto-prefix for known providers + spec = find_by_model(model) + if spec and spec.litellm_prefix: + model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix) + if not any(model.startswith(s) for s in spec.skip_prefixes): + model = f"{spec.litellm_prefix}/{model}" + + return model + + @staticmethod + def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str: + """Normalize explicit provider prefixes like `github-copilot/...`.""" + if "/" not in model: + return model + prefix, remainder = model.split("/", 1) + if prefix.lower().replace("-", "_") != spec_name: + return model + return f"{canonical_prefix}/{remainder}" + + def _supports_cache_control(self, model: str) -> bool: + """Return True when the provider supports cache_control on content blocks.""" + if self._gateway is not None: + return self._gateway.supports_prompt_caching + spec = find_by_model(model) + return spec is not None and spec.supports_prompt_caching + + def _apply_cache_control( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]: + """Return copies of messages and tools with cache_control injected.""" + new_messages = [] + for msg in messages: + if msg.get("role") == "system": + content = msg["content"] + if isinstance(content, str): + new_content = [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}] + else: + new_content = list(content) + new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}} + new_messages.append({**msg, "content": new_content}) + else: + new_messages.append(msg) + + new_tools = tools + if tools: + new_tools = list(tools) + new_tools[-1] = {**new_tools[-1], "cache_control": {"type": "ephemeral"}} + + return new_messages, new_tools + + def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None: + """Apply model-specific parameter overrides from the registry.""" + model_lower = model.lower() + spec = find_by_model(model) + if spec: + for pattern, overrides in spec.model_overrides: + if pattern in model_lower: + kwargs.update(overrides) + return + + @staticmethod + def _extra_msg_keys(original_model: str, resolved_model: str) -> frozenset[str]: + """Return provider-specific extra keys to preserve in request messages.""" + spec = find_by_model(original_model) or find_by_model(resolved_model) + if (spec and spec.name == "anthropic") or "claude" in original_model.lower() or resolved_model.startswith("anthropic/"): + return _ANTHROPIC_EXTRA_KEYS + return frozenset() + + @staticmethod + def _normalize_tool_call_id(tool_call_id: Any) -> Any: + """Normalize tool_call_id to a provider-safe 9-char alphanumeric form.""" + if not isinstance(tool_call_id, str): + return tool_call_id + if len(tool_call_id) == 9 and tool_call_id.isalnum(): + return tool_call_id + return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9] + + @staticmethod + def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]: + """Strip non-standard keys and ensure assistant messages have a content key.""" + allowed = _ALLOWED_MSG_KEYS | extra_keys + sanitized = LLMProvider._sanitize_request_messages(messages, allowed) + id_map: dict[str, str] = {} + + def map_id(value: Any) -> Any: + if not isinstance(value, str): + return value + return id_map.setdefault(value, LiteLLMProvider._normalize_tool_call_id(value)) + + for clean in sanitized: + # Keep assistant tool_calls[].id and tool tool_call_id in sync after + # shortening, otherwise strict providers reject the broken linkage. + if isinstance(clean.get("tool_calls"), list): + normalized_tool_calls = [] + for tc in clean["tool_calls"]: + if not isinstance(tc, dict): + normalized_tool_calls.append(tc) + continue + tc_clean = dict(tc) + tc_clean["id"] = map_id(tc_clean.get("id")) + normalized_tool_calls.append(tc_clean) + clean["tool_calls"] = normalized_tool_calls + + if "tool_call_id" in clean and clean["tool_call_id"]: + clean["tool_call_id"] = map_id(clean["tool_call_id"]) + return sanitized + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> LLMResponse: + """ + Send a chat completion request via LiteLLM. + + Args: + messages: List of message dicts with 'role' and 'content'. + tools: Optional list of tool definitions in OpenAI format. + model: Model identifier (e.g., 'anthropic/claude-sonnet-4-5'). + max_tokens: Maximum tokens in response. + temperature: Sampling temperature. + + Returns: + LLMResponse with content and/or tool calls. + """ + original_model = model or self.default_model + model = self._resolve_model(original_model) + extra_msg_keys = self._extra_msg_keys(original_model, model) + + if self._supports_cache_control(original_model): + messages, tools = self._apply_cache_control(messages, tools) + + # Clamp max_tokens to at least 1 — negative or zero values cause + # LiteLLM to reject the request with "max_tokens must be at least 1". + max_tokens = max(1, max_tokens) + + kwargs: dict[str, Any] = { + "model": model, + "messages": self._sanitize_messages(self._sanitize_empty_content(messages), extra_keys=extra_msg_keys), + "max_tokens": max_tokens, + "temperature": temperature, + } + + # Apply model-specific overrides (e.g. kimi-k2.5 temperature) + self._apply_model_overrides(model, kwargs) + + if self._langsmith_enabled: + kwargs.setdefault("callbacks", []).append("langsmith") + + # Pass api_key directly — more reliable than env vars alone + if self.api_key: + kwargs["api_key"] = self.api_key + + # Pass api_base for custom endpoints + if self.api_base: + kwargs["api_base"] = self.api_base + + # Pass extra headers (e.g. APP-Code for AiHubMix) + if self.extra_headers: + kwargs["extra_headers"] = self.extra_headers + + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort + kwargs["drop_params"] = True + + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = tool_choice or "auto" + + try: + response = await acompletion(**kwargs) + return self._parse_response(response) + except Exception as e: + # Return error as content for graceful handling + return LLMResponse( + content=f"Error calling LLM: {str(e)}", + finish_reason="error", + ) + + def _parse_response(self, response: Any) -> LLMResponse: + """Parse LiteLLM response into our standard format.""" + choice = response.choices[0] + message = choice.message + content = message.content + finish_reason = choice.finish_reason + + # Some providers (e.g. GitHub Copilot) split content and tool_calls + # across multiple choices. Merge them so tool_calls are not lost. + raw_tool_calls = [] + for ch in response.choices: + msg = ch.message + if hasattr(msg, "tool_calls") and msg.tool_calls: + raw_tool_calls.extend(msg.tool_calls) + if ch.finish_reason in ("tool_calls", "stop"): + finish_reason = ch.finish_reason + if not content and msg.content: + content = msg.content + + if len(response.choices) > 1: + logger.debug("LiteLLM response has {} choices, merged {} tool_calls", + len(response.choices), len(raw_tool_calls)) + + tool_calls = [] + for tc in raw_tool_calls: + # Parse arguments from JSON string if needed + args = tc.function.arguments + if isinstance(args, str): + args = json_repair.loads(args) + + provider_specific_fields = getattr(tc, "provider_specific_fields", None) or None + function_provider_specific_fields = ( + getattr(tc.function, "provider_specific_fields", None) or None + ) + + tool_calls.append(ToolCallRequest( + id=_short_tool_id(), + name=tc.function.name, + arguments=args, + provider_specific_fields=provider_specific_fields, + function_provider_specific_fields=function_provider_specific_fields, + )) + + usage = {} + if hasattr(response, "usage") and response.usage: + usage = { + "prompt_tokens": response.usage.prompt_tokens, + "completion_tokens": response.usage.completion_tokens, + "total_tokens": response.usage.total_tokens, + } + + reasoning_content = getattr(message, "reasoning_content", None) or None + thinking_blocks = getattr(message, "thinking_blocks", None) or None + + return LLMResponse( + content=content, + tool_calls=tool_calls, + finish_reason=finish_reason or "stop", + usage=usage, + reasoning_content=reasoning_content, + thinking_blocks=thinking_blocks, + ) + + def get_default_model(self) -> str: + """Get the default model.""" + return self.default_model diff --git a/deeptutor/tutorbot/providers/openai_compat_provider.py b/deeptutor/tutorbot/providers/openai_compat_provider.py deleted file mode 100644 index 4706fc578..000000000 --- a/deeptutor/tutorbot/providers/openai_compat_provider.py +++ /dev/null @@ -1,551 +0,0 @@ -"""OpenAI-compatible provider for all non-Anthropic LLM APIs. - -Uses the official ``openai.AsyncOpenAI`` SDK to talk to any OpenAI-compatible -endpoint (OpenAI, DeepSeek, Gemini, Moonshot, MiniMax, gateways, local, etc.). -""" - -from __future__ import annotations - -import asyncio -import hashlib -import secrets -import string -import uuid -from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any - -import json_repair -from openai import AsyncOpenAI - -from deeptutor.tutorbot.providers.base import LLMProvider, LLMResponse, ToolCallRequest - -if TYPE_CHECKING: - from deeptutor.tutorbot.providers.registry import ProviderSpec - -_ALLOWED_MSG_KEYS = frozenset({ - "role", "content", "tool_calls", "tool_call_id", "name", - "reasoning_content", "extra_content", -}) -_ALNUM = string.ascii_letters + string.digits - -_DEFAULT_OPENROUTER_HEADERS = { - "HTTP-Referer": "https://github.com/HKUDS/DeepTutor", - "X-OpenRouter-Title": "DeepTutor", -} - - -def _short_tool_id() -> str: - """9-char alphanumeric ID compatible with all providers (incl. Mistral).""" - return "".join(secrets.choice(_ALNUM) for _ in range(9)) - - -def _get(obj: Any, key: str) -> Any: - if isinstance(obj, dict): - return obj.get(key) - return getattr(obj, key, None) - - -def _coerce_dict(value: Any) -> dict[str, Any] | None: - if value is None: - return None - if isinstance(value, dict): - return value if value else None - model_dump = getattr(value, "model_dump", None) - if callable(model_dump): - dumped = model_dump() - if isinstance(dumped, dict) and dumped: - return dumped - return None - - -def _uses_openrouter(spec: "ProviderSpec | None", api_base: str | None) -> bool: - if spec and spec.name == "openrouter": - return True - return bool(api_base and "openrouter" in api_base.lower()) - - -class OpenAICompatProvider(LLMProvider): - """Unified provider for all OpenAI-compatible APIs. - - Receives a resolved ``ProviderSpec`` from the caller — no internal - registry lookups needed. - """ - - def __init__( - self, - api_key: str | None = None, - api_base: str | None = None, - default_model: str = "gpt-4o", - extra_headers: dict[str, str] | None = None, - spec: ProviderSpec | None = None, - provider_name: str | None = None, - ): - super().__init__(api_key, api_base) - self.default_model = default_model - self.extra_headers = extra_headers or {} - self._spec = spec - self._provider_name = provider_name - - if api_key and spec and spec.env_key: - self._setup_env(api_key, api_base) - - effective_base = api_base or (spec.default_api_base if spec else None) or None - default_headers: dict[str, str] = {"x-session-affinity": uuid.uuid4().hex} - if _uses_openrouter(spec, effective_base): - default_headers.update(_DEFAULT_OPENROUTER_HEADERS) - if extra_headers: - default_headers.update(extra_headers) - - self._client = AsyncOpenAI( - api_key=api_key or "no-key", - base_url=effective_base, - default_headers=default_headers, - max_retries=0, - ) - - def _setup_env(self, api_key: str, api_base: str | None) -> None: - import os - - spec = self._spec - if not spec or not spec.env_key: - return - if spec.is_gateway: - os.environ[spec.env_key] = api_key - else: - os.environ.setdefault(spec.env_key, api_key) - effective_base = api_base or spec.default_api_base - for env_name, env_val in spec.env_extras: - resolved = env_val.replace("{api_key}", api_key).replace("{api_base}", effective_base or "") - os.environ.setdefault(env_name, resolved) - - # ------------------------------------------------------------------ - # Prompt caching - # ------------------------------------------------------------------ - - @classmethod - def _apply_cache_control( - cls, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None, - ) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]: - cache_marker = {"type": "ephemeral"} - new_messages = list(messages) - - def _mark(msg: dict[str, Any]) -> dict[str, Any]: - content = msg.get("content") - if isinstance(content, str): - return {**msg, "content": [ - {"type": "text", "text": content, "cache_control": cache_marker}, - ]} - if isinstance(content, list) and content: - nc = list(content) - nc[-1] = {**nc[-1], "cache_control": cache_marker} - return {**msg, "content": nc} - return msg - - if new_messages and new_messages[0].get("role") == "system": - new_messages[0] = _mark(new_messages[0]) - if len(new_messages) >= 3: - new_messages[-2] = _mark(new_messages[-2]) - - new_tools = tools - if tools: - new_tools = list(tools) - for idx in cls._tool_cache_marker_indices(new_tools): - new_tools[idx] = {**new_tools[idx], "cache_control": cache_marker} - return new_messages, new_tools - - # ------------------------------------------------------------------ - # Message sanitization - # ------------------------------------------------------------------ - - @staticmethod - def _normalize_tool_call_id(tool_call_id: Any) -> Any: - if not isinstance(tool_call_id, str): - return tool_call_id - if len(tool_call_id) == 9 and tool_call_id.isalnum(): - return tool_call_id - return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9] - - def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS) - id_map: dict[str, str] = {} - - def map_id(value: Any) -> Any: - if not isinstance(value, str): - return value - return id_map.setdefault(value, self._normalize_tool_call_id(value)) - - for clean in sanitized: - if isinstance(clean.get("tool_calls"), list): - normalized = [] - for tc in clean["tool_calls"]: - if not isinstance(tc, dict): - normalized.append(tc) - continue - tc_clean = dict(tc) - tc_clean["id"] = map_id(tc_clean.get("id")) - normalized.append(tc_clean) - clean["tool_calls"] = normalized - if "tool_call_id" in clean and clean["tool_call_id"]: - clean["tool_call_id"] = map_id(clean["tool_call_id"]) - return sanitized - - # ------------------------------------------------------------------ - # Build kwargs - # ------------------------------------------------------------------ - - @staticmethod - def _supports_temperature( - model_name: str, - reasoning_effort: str | None = None, - ) -> bool: - if reasoning_effort and reasoning_effort.lower() != "none": - return False - name = model_name.lower() - return not any(token in name for token in ("gpt-5", "o1", "o3", "o4")) - - def _build_kwargs( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None, - model: str | None, - max_tokens: int, - temperature: float, - reasoning_effort: str | None, - tool_choice: str | dict[str, Any] | None, - ) -> dict[str, Any]: - model_name = model or self.default_model - spec = self._spec - - if spec and spec.supports_prompt_caching: - if any(model_name.lower().startswith(k) for k in ("anthropic/", "claude")): - messages, tools = self._apply_cache_control(messages, tools) - - if spec and spec.strip_model_prefix: - model_name = model_name.split("/")[-1] - - kwargs: dict[str, Any] = { - "model": model_name, - "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), - } - - if self._supports_temperature(model_name, reasoning_effort): - kwargs["temperature"] = temperature - - if spec and getattr(spec, "supports_max_completion_tokens", False): - kwargs["max_completion_tokens"] = max(1, max_tokens) - else: - kwargs["max_tokens"] = max(1, max_tokens) - - if spec: - model_lower = model_name.lower() - for pattern, overrides in spec.model_overrides: - if pattern in model_lower: - kwargs.update(overrides) - break - - if reasoning_effort: - kwargs["reasoning_effort"] = reasoning_effort - - if spec and reasoning_effort is not None: - thinking_enabled = reasoning_effort.lower() != "minimal" - extra: dict[str, Any] | None = None - if spec.name == "dashscope": - extra = {"enable_thinking": thinking_enabled} - elif spec.name in ( - "volcengine", "volcengine_coding_plan", - "byteplus", "byteplus_coding_plan", - ): - extra = { - "thinking": {"type": "enabled" if thinking_enabled else "disabled"} - } - if extra: - kwargs.setdefault("extra_body", {}).update(extra) - - if tools: - kwargs["tools"] = tools - kwargs["tool_choice"] = tool_choice or "auto" - - return kwargs - - # ------------------------------------------------------------------ - # Response parsing - # ------------------------------------------------------------------ - - @staticmethod - def _maybe_mapping(value: Any) -> dict[str, Any] | None: - if isinstance(value, dict): - return value - model_dump = getattr(value, "model_dump", None) - if callable(model_dump): - dumped = model_dump() - if isinstance(dumped, dict): - return dumped - return None - - @classmethod - def _extract_text_content(cls, value: Any) -> str | None: - if value is None: - return None - if isinstance(value, str): - return value - if isinstance(value, list): - parts: list[str] = [] - for item in value: - item_map = cls._maybe_mapping(item) - if item_map: - text = item_map.get("text") - if isinstance(text, str): - parts.append(text) - continue - text = getattr(item, "text", None) - if isinstance(text, str): - parts.append(text) - continue - if isinstance(item, str): - parts.append(item) - return "".join(parts) or None - return str(value) - - @classmethod - def _extract_usage(cls, response: Any) -> dict[str, int]: - usage_obj = None - response_map = cls._maybe_mapping(response) - if response_map is not None: - usage_obj = response_map.get("usage") - elif hasattr(response, "usage") and response.usage: - usage_obj = response.usage - - usage_map = cls._maybe_mapping(usage_obj) - if usage_map is not None: - return { - "prompt_tokens": int(usage_map.get("prompt_tokens") or 0), - "completion_tokens": int(usage_map.get("completion_tokens") or 0), - "total_tokens": int(usage_map.get("total_tokens") or 0), - } - if usage_obj: - return { - "prompt_tokens": getattr(usage_obj, "prompt_tokens", 0) or 0, - "completion_tokens": getattr(usage_obj, "completion_tokens", 0) or 0, - "total_tokens": getattr(usage_obj, "total_tokens", 0) or 0, - } - return {} - - def _parse(self, response: Any) -> LLMResponse: - if isinstance(response, str): - return LLMResponse(content=response, finish_reason="stop") - - if not response.choices: - return LLMResponse(content="Error: API returned empty choices.", finish_reason="error") - - choice = response.choices[0] - msg = choice.message - content = msg.content - finish_reason = choice.finish_reason - - raw_tool_calls: list[Any] = [] - for ch in response.choices: - m = ch.message - if hasattr(m, "tool_calls") and m.tool_calls: - raw_tool_calls.extend(m.tool_calls) - if ch.finish_reason in ("tool_calls", "stop"): - finish_reason = ch.finish_reason - if not content and m.content: - content = m.content - if not content and getattr(m, "reasoning", None): - content = m.reasoning - - tool_calls = [] - for tc in raw_tool_calls: - args = tc.function.arguments - if isinstance(args, str): - args = json_repair.loads(args) - tool_calls.append(ToolCallRequest( - id=_short_tool_id(), - name=tc.function.name, - arguments=args if isinstance(args, dict) else {}, - provider_specific_fields=getattr(tc, "provider_specific_fields", None) or None, - function_provider_specific_fields=( - getattr(tc.function, "provider_specific_fields", None) or None - ), - )) - - reasoning_content = getattr(msg, "reasoning_content", None) or None - if not reasoning_content and getattr(msg, "reasoning", None): - reasoning_content = msg.reasoning - - usage = self._extract_usage(response) - - return LLMResponse( - content=content, - tool_calls=tool_calls, - finish_reason=finish_reason or "stop", - usage=usage, - reasoning_content=reasoning_content, - ) - - @classmethod - def _parse_chunks(cls, chunks: list[Any]) -> LLMResponse: - content_parts: list[str] = [] - reasoning_parts: list[str] = [] - tc_bufs: dict[int, dict[str, Any]] = {} - finish_reason = "stop" - usage: dict[str, int] = {} - - def _accum_tc(tc: Any, idx_hint: int) -> None: - tc_index: int = _get(tc, "index") if _get(tc, "index") is not None else idx_hint - buf = tc_bufs.setdefault(tc_index, { - "id": "", "name": "", "arguments": "", - }) - tc_id = _get(tc, "id") - if tc_id: - buf["id"] = str(tc_id) - fn = _get(tc, "function") - if fn is not None: - fn_name = _get(fn, "name") - if fn_name: - buf["name"] = str(fn_name) - fn_args = _get(fn, "arguments") - if fn_args: - buf["arguments"] += str(fn_args) - - for chunk in chunks: - if isinstance(chunk, str): - content_parts.append(chunk) - continue - - if not chunk.choices: - usage = cls._extract_usage(chunk) or usage - continue - choice = chunk.choices[0] - if choice.finish_reason: - finish_reason = choice.finish_reason - delta = choice.delta - if delta and delta.content: - content_parts.append(delta.content) - if delta: - reasoning = getattr(delta, "reasoning_content", None) - if not reasoning: - reasoning = getattr(delta, "reasoning", None) - if reasoning: - reasoning_parts.append(reasoning) - for tc in (delta.tool_calls or []) if delta else []: - _accum_tc(tc, getattr(tc, "index", 0)) - - return LLMResponse( - content="".join(content_parts) or None, - tool_calls=[ - ToolCallRequest( - id=b["id"] or _short_tool_id(), - name=b["name"], - arguments=json_repair.loads(b["arguments"]) if b["arguments"] else {}, - ) - for b in tc_bufs.values() - ], - finish_reason=finish_reason, - usage=usage, - reasoning_content="".join(reasoning_parts) or None, - ) - - @staticmethod - def _handle_error(e: Exception) -> LLMResponse: - body = ( - getattr(e, "doc", None) - or getattr(e, "body", None) - or getattr(getattr(e, "response", None), "text", None) - ) - body_text = body if isinstance(body, str) else str(body) if body is not None else "" - msg = f"Error: {body_text.strip()[:500]}" if body_text.strip() else f"Error calling LLM: {e}" - return LLMResponse(content=msg, finish_reason="error") - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - @staticmethod - def _is_tool_format_error(e: Exception) -> bool: - """Detect errors caused by strict tool-argument JSON validation. - - Some endpoints (e.g. DashScope Coding Plan) reject non-streaming - tool calls with 400 when the model produces malformed arguments. - Streaming avoids this because the SDK accumulates tokens into a - well-formed response. - """ - text = str(getattr(e, "body", None) or getattr(e, "message", None) or e).lower() - return any(kw in text for kw in ( - "function.arguments", - "must be in json format", - "invalid.*parameter.*function", - )) - - async def chat( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None = None, - model: str | None = None, - max_tokens: int = 4096, - temperature: float = 0.7, - reasoning_effort: str | None = None, - tool_choice: str | dict[str, Any] | None = None, - ) -> LLMResponse: - kwargs = self._build_kwargs( - messages, tools, model, max_tokens, temperature, - reasoning_effort, tool_choice, - ) - try: - return self._parse(await self._client.chat.completions.create(**kwargs)) - except Exception as e: - if tools and self._is_tool_format_error(e): - return await self.chat_stream( - messages, tools, model, max_tokens, temperature, - reasoning_effort, tool_choice, - ) - return self._handle_error(e) - - async def chat_stream( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None = None, - model: str | None = None, - max_tokens: int = 4096, - temperature: float = 0.7, - reasoning_effort: str | None = None, - tool_choice: str | dict[str, Any] | None = None, - on_content_delta: Callable[[str], Awaitable[None]] | None = None, - ) -> LLMResponse: - kwargs = self._build_kwargs( - messages, tools, model, max_tokens, temperature, - reasoning_effort, tool_choice, - ) - kwargs["stream"] = True - kwargs["stream_options"] = {"include_usage": True} - idle_timeout_s = 90 - try: - stream = await self._client.chat.completions.create(**kwargs) - chunks: list[Any] = [] - stream_iter = stream.__aiter__() - while True: - try: - chunk = await asyncio.wait_for( - stream_iter.__anext__(), - timeout=idle_timeout_s, - ) - except StopAsyncIteration: - break - chunks.append(chunk) - if on_content_delta and chunk.choices: - text = getattr(chunk.choices[0].delta, "content", None) - if text: - await on_content_delta(text) - return self._parse_chunks(chunks) - except asyncio.TimeoutError: - return LLMResponse( - content=f"Error calling LLM: stream stalled for more than {idle_timeout_s} seconds", - finish_reason="error", - ) - except Exception as e: - return self._handle_error(e) - - def get_default_model(self) -> str: - return self.default_model diff --git a/deeptutor/tutorbot/providers/registry.py b/deeptutor/tutorbot/providers/registry.py index b4b5732ec..bee6c5eb1 100644 --- a/deeptutor/tutorbot/providers/registry.py +++ b/deeptutor/tutorbot/providers/registry.py @@ -3,7 +3,8 @@ Adding a new provider: 1. Add a ProviderSpec to PROVIDERS below. - Done. Env vars, config matching, status display all derive from here. + 2. Add a field to ProvidersConfig in config/schema.py. + Done. Env vars, prefixing, config matching, status display all derive from here. Order matters — it controls match priority and fallback. Gateways first. Every entry writes out all fields so you can copy-paste as a template. @@ -25,53 +26,40 @@ class ProviderSpec: """ # identity - name: str - keywords: tuple[str, ...] - env_key: str - display_name: str = "" + name: str # config field name, e.g. "dashscope" + keywords: tuple[str, ...] # model-name keywords for matching (lowercase) + env_key: str # LiteLLM env var, e.g. "DASHSCOPE_API_KEY" + display_name: str = "" # shown in status output - # Which provider implementation to use: - # "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" - backend: str = "openai_compat" + # model prefixing + litellm_prefix: str = "" # "dashscope" → model becomes "dashscope/{model}" + skip_prefixes: tuple[str, ...] = () # don't prefix if model already starts with these # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),) env_extras: tuple[tuple[str, str], ...] = () # gateway / local detection - is_gateway: bool = False - is_local: bool = False - detect_by_key_prefix: str = "" - detect_by_base_keyword: str = "" - default_api_base: str = "" + is_gateway: bool = False # routes any model (OpenRouter, AiHubMix) + is_local: bool = False # local deployment (vLLM, Ollama) + detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-" + detect_by_base_keyword: str = "" # match substring in api_base URL + default_api_base: str = "" # fallback base URL # gateway behavior - strip_model_prefix: bool = False - supports_max_completion_tokens: bool = False + strip_model_prefix: bool = False # strip "provider/" before re-prefixing # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),) model_overrides: tuple[tuple[str, dict[str, Any]], ...] = () # OAuth-based providers (e.g., OpenAI Codex) don't use API keys - is_oauth: bool = False + is_oauth: bool = False # if True, uses OAuth flow instead of API key - # Direct providers skip API-key validation + # Direct providers bypass LiteLLM entirely (e.g., CustomProvider) is_direct: bool = False # Provider supports cache_control on content blocks (e.g. Anthropic prompt caching) supports_prompt_caching: bool = False - @property - def mode(self) -> str: - if self.is_oauth: - return "oauth" - if self.is_direct: - return "direct" - if self.is_gateway: - return "gateway" - if self.is_local: - return "local" - return "standard" - @property def label(self) -> str: return self.display_name or self.name.title() @@ -82,256 +70,388 @@ def label(self) -> str: # --------------------------------------------------------------------------- PROVIDERS: tuple[ProviderSpec, ...] = ( - # === Direct (user supplies everything) ================================== + # === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ====== ProviderSpec( name="custom", keywords=(), env_key="", display_name="Custom", - backend="openai_compat", + litellm_prefix="", is_direct=True, ), + + # === Azure OpenAI (direct API calls with API version 2024-10-21) ===== ProviderSpec( name="azure_openai", keywords=("azure", "azure-openai"), env_key="", display_name="Azure OpenAI", - backend="azure_openai", + litellm_prefix="", is_direct=True, ), - # === Gateways (detected by api_key / api_base, route any model) ======== + # === Gateways (detected by api_key / api_base, not model name) ========= + # Gateways can route any model, so they win in fallback. + # OpenRouter: global gateway, keys start with "sk-or-" ProviderSpec( name="openrouter", keywords=("openrouter",), env_key="OPENROUTER_API_KEY", display_name="OpenRouter", - backend="openai_compat", + litellm_prefix="openrouter", # claude-3 → openrouter/claude-3 + skip_prefixes=(), + env_extras=(), is_gateway=True, + is_local=False, detect_by_key_prefix="sk-or-", detect_by_base_keyword="openrouter", default_api_base="https://openrouter.ai/api/v1", + strip_model_prefix=False, + model_overrides=(), supports_prompt_caching=True, ), + # AiHubMix: global gateway, OpenAI-compatible interface. + # strip_model_prefix=True: it doesn't understand "anthropic/claude-3", + # so we strip to bare "claude-3" then re-prefix as "openai/claude-3". ProviderSpec( name="aihubmix", keywords=("aihubmix",), - env_key="OPENAI_API_KEY", + env_key="OPENAI_API_KEY", # OpenAI-compatible display_name="AiHubMix", - backend="openai_compat", + litellm_prefix="openai", # → openai/{model} + skip_prefixes=(), + env_extras=(), is_gateway=True, + is_local=False, + detect_by_key_prefix="", detect_by_base_keyword="aihubmix", default_api_base="https://aihubmix.com/v1", - strip_model_prefix=True, + strip_model_prefix=True, # anthropic/claude-3 → claude-3 → openai/claude-3 + model_overrides=(), ), + # SiliconFlow (硅基流动): OpenAI-compatible gateway, model names keep org prefix ProviderSpec( name="siliconflow", keywords=("siliconflow",), env_key="OPENAI_API_KEY", display_name="SiliconFlow", - backend="openai_compat", + litellm_prefix="openai", + skip_prefixes=(), + env_extras=(), is_gateway=True, + is_local=False, + detect_by_key_prefix="", detect_by_base_keyword="siliconflow", default_api_base="https://api.siliconflow.cn/v1", + strip_model_prefix=False, + model_overrides=(), ), + + # VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models ProviderSpec( name="volcengine", keywords=("volcengine", "volces", "ark"), env_key="OPENAI_API_KEY", display_name="VolcEngine", - backend="openai_compat", + litellm_prefix="volcengine", + skip_prefixes=(), + env_extras=(), is_gateway=True, + is_local=False, + detect_by_key_prefix="", detect_by_base_keyword="volces", default_api_base="https://ark.cn-beijing.volces.com/api/v3", + strip_model_prefix=False, + model_overrides=(), ), + + # VolcEngine Coding Plan (火山引擎 Coding Plan): same key as volcengine ProviderSpec( name="volcengine_coding_plan", keywords=("volcengine-plan",), env_key="OPENAI_API_KEY", display_name="VolcEngine Coding Plan", - backend="openai_compat", + litellm_prefix="volcengine", + skip_prefixes=(), + env_extras=(), is_gateway=True, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", default_api_base="https://ark.cn-beijing.volces.com/api/coding/v3", strip_model_prefix=True, + model_overrides=(), ), + + # BytePlus: VolcEngine international, pay-per-use models ProviderSpec( name="byteplus", keywords=("byteplus",), env_key="OPENAI_API_KEY", display_name="BytePlus", - backend="openai_compat", + litellm_prefix="volcengine", + skip_prefixes=(), + env_extras=(), is_gateway=True, + is_local=False, + detect_by_key_prefix="", detect_by_base_keyword="bytepluses", default_api_base="https://ark.ap-southeast.bytepluses.com/api/v3", strip_model_prefix=True, + model_overrides=(), ), + + # BytePlus Coding Plan: same key as byteplus ProviderSpec( name="byteplus_coding_plan", keywords=("byteplus-plan",), env_key="OPENAI_API_KEY", display_name="BytePlus Coding Plan", - backend="openai_compat", + litellm_prefix="volcengine", + skip_prefixes=(), + env_extras=(), is_gateway=True, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", default_api_base="https://ark.ap-southeast.bytepluses.com/api/coding/v3", strip_model_prefix=True, + model_overrides=(), ), + + # === Standard providers (matched by model-name keywords) =============== + # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed. ProviderSpec( name="anthropic", keywords=("anthropic", "claude"), env_key="ANTHROPIC_API_KEY", display_name="Anthropic", - backend="anthropic", - default_api_base="https://api.anthropic.com/v1", + litellm_prefix="", + skip_prefixes=(), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), supports_prompt_caching=True, ), + # OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed. ProviderSpec( name="openai", keywords=("openai", "gpt"), env_key="OPENAI_API_KEY", display_name="OpenAI", - backend="openai_compat", - supports_max_completion_tokens=True, + litellm_prefix="", + skip_prefixes=(), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), ), + # OpenAI Codex: uses OAuth, not API key. ProviderSpec( name="openai_codex", keywords=("openai-codex",), - env_key="", + env_key="", # OAuth-based, no API key display_name="OpenAI Codex", - backend="openai_codex", + litellm_prefix="", # Not routed through LiteLLM + skip_prefixes=(), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", detect_by_base_keyword="codex", default_api_base="https://chatgpt.com/backend-api", - is_oauth=True, + strip_model_prefix=False, + model_overrides=(), + is_oauth=True, # OAuth-based authentication ), + # Github Copilot: uses OAuth, not API key. ProviderSpec( name="github_copilot", keywords=("github_copilot", "copilot"), - env_key="", + env_key="", # OAuth-based, no API key display_name="Github Copilot", - backend="github_copilot", - default_api_base="https://api.githubcopilot.com", - strip_model_prefix=True, - is_oauth=True, + litellm_prefix="github_copilot", # github_copilot/model → github_copilot/model + skip_prefixes=("github_copilot/",), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), + is_oauth=True, # OAuth-based authentication ), + # DeepSeek: needs "deepseek/" prefix for LiteLLM routing. ProviderSpec( name="deepseek", keywords=("deepseek",), env_key="DEEPSEEK_API_KEY", display_name="DeepSeek", - backend="openai_compat", - default_api_base="https://api.deepseek.com", + litellm_prefix="deepseek", # deepseek-chat → deepseek/deepseek-chat + skip_prefixes=("deepseek/",), # avoid double-prefix + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), ), + # Gemini: needs "gemini/" prefix for LiteLLM. ProviderSpec( name="gemini", keywords=("gemini",), env_key="GEMINI_API_KEY", display_name="Gemini", - backend="openai_compat", - default_api_base="https://generativelanguage.googleapis.com/v1beta/openai/", + litellm_prefix="gemini", # gemini-pro → gemini/gemini-pro + skip_prefixes=("gemini/",), # avoid double-prefix + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), ), + # Zhipu: LiteLLM uses "zai/" prefix. + # Also mirrors key to ZHIPUAI_API_KEY (some LiteLLM paths check that). + # skip_prefixes: don't add "zai/" when already routed via gateway. ProviderSpec( name="zhipu", keywords=("zhipu", "glm", "zai"), env_key="ZAI_API_KEY", display_name="Zhipu AI", - backend="openai_compat", + litellm_prefix="zai", # glm-4 → zai/glm-4 + skip_prefixes=("zhipu/", "zai/", "openrouter/", "hosted_vllm/"), env_extras=(("ZHIPUAI_API_KEY", "{api_key}"),), - default_api_base="https://open.bigmodel.cn/api/paas/v4", + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), ), + # DashScope: Qwen models, needs "dashscope/" prefix. ProviderSpec( name="dashscope", keywords=("qwen", "dashscope"), env_key="DASHSCOPE_API_KEY", display_name="DashScope", - backend="openai_compat", - default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", + litellm_prefix="dashscope", # qwen-max → dashscope/qwen-max + skip_prefixes=("dashscope/", "openrouter/"), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), ), + # Moonshot: Kimi models, needs "moonshot/" prefix. + # LiteLLM requires MOONSHOT_API_BASE env var to find the endpoint. + # Kimi K2.5 API enforces temperature >= 1.0. ProviderSpec( name="moonshot", keywords=("moonshot", "kimi"), env_key="MOONSHOT_API_KEY", display_name="Moonshot", - backend="openai_compat", - default_api_base="https://api.moonshot.ai/v1", + litellm_prefix="moonshot", # kimi-k2.5 → moonshot/kimi-k2.5 + skip_prefixes=("moonshot/", "openrouter/"), + env_extras=(("MOONSHOT_API_BASE", "{api_base}"),), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="https://api.moonshot.ai/v1", # intl; use api.moonshot.cn for China + strip_model_prefix=False, model_overrides=(("kimi-k2.5", {"temperature": 1.0}),), ), + # MiniMax: needs "minimax/" prefix for LiteLLM routing. + # Uses OpenAI-compatible API at api.minimax.io/v1. ProviderSpec( name="minimax", keywords=("minimax",), env_key="MINIMAX_API_KEY", display_name="MiniMax", - backend="openai_compat", + litellm_prefix="minimax", # MiniMax-M2.1 → minimax/MiniMax-M2.1 + skip_prefixes=("minimax/", "openrouter/"), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", default_api_base="https://api.minimax.io/v1", + strip_model_prefix=False, + model_overrides=(), ), - ProviderSpec( - name="mistral", - keywords=("mistral",), - env_key="MISTRAL_API_KEY", - display_name="Mistral", - backend="openai_compat", - default_api_base="https://api.mistral.ai/v1", - ), - ProviderSpec( - name="stepfun", - keywords=("stepfun", "step"), - env_key="STEPFUN_API_KEY", - display_name="Step Fun", - backend="openai_compat", - default_api_base="https://api.stepfun.com/v1", - ), - ProviderSpec( - name="xiaomi_mimo", - keywords=("xiaomi_mimo", "mimo"), - env_key="XIAOMIMIMO_API_KEY", - display_name="Xiaomi MIMO", - backend="openai_compat", - default_api_base="https://api.xiaomimimo.com/v1", - ), - # === Local deployment ================================================== + # === Local deployment (matched by config key, NOT by api_base) ========= + # vLLM / any OpenAI-compatible local server. + # Detected when config key is "vllm" (provider_name="vllm"). ProviderSpec( name="vllm", keywords=("vllm",), env_key="HOSTED_VLLM_API_KEY", display_name="vLLM/Local", - backend="openai_compat", + litellm_prefix="hosted_vllm", # Llama-3-8B → hosted_vllm/Llama-3-8B + skip_prefixes=(), + env_extras=(), + is_gateway=False, is_local=True, - default_api_base="http://localhost:8000/v1", + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", # user must provide in config + strip_model_prefix=False, + model_overrides=(), ), + # === Ollama (local, OpenAI-compatible) =================================== ProviderSpec( name="ollama", keywords=("ollama", "nemotron"), env_key="OLLAMA_API_KEY", display_name="Ollama", - backend="openai_compat", + litellm_prefix="ollama_chat", # model → ollama_chat/model + skip_prefixes=("ollama/", "ollama_chat/"), + env_extras=(), + is_gateway=False, is_local=True, + detect_by_key_prefix="", detect_by_base_keyword="11434", - default_api_base="http://localhost:11434/v1", - ), - ProviderSpec( - name="ovms", - keywords=("openvino", "ovms"), - env_key="", - display_name="OpenVINO Model Server", - backend="openai_compat", - is_direct=True, - is_local=True, - default_api_base="http://localhost:8000/v3", + default_api_base="http://localhost:11434", + strip_model_prefix=False, + model_overrides=(), ), - # === Auxiliary ========================================================== + # === Auxiliary (not a primary LLM provider) ============================ + # Groq: mainly used for Whisper voice transcription, also usable for LLM. + # Needs "groq/" prefix for LiteLLM routing. Placed last — it rarely wins fallback. ProviderSpec( name="groq", keywords=("groq",), env_key="GROQ_API_KEY", display_name="Groq", - backend="openai_compat", - default_api_base="https://api.groq.com/openai/v1", - ), - ProviderSpec( - name="qianfan", - keywords=("qianfan", "ernie"), - env_key="QIANFAN_API_KEY", - display_name="Qianfan", - backend="openai_compat", - default_api_base="https://qianfan.baidubce.com/v2", + litellm_prefix="groq", # llama3-8b-8192 → groq/llama3-8b-8192 + skip_prefixes=("groq/",), # avoid double-prefix + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), ), ) @@ -342,16 +462,19 @@ def label(self) -> str: def find_by_model(model: str) -> ProviderSpec | None: - """Match a standard provider by model-name keyword (case-insensitive).""" + """Match a standard provider by model-name keyword (case-insensitive). + Skips gateways/local — those are matched by api_key/api_base instead.""" model_lower = model.lower() model_normalized = model_lower.replace("-", "_") model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") std_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local] + # Prefer explicit provider prefix — prevents `github-copilot/...codex` matching openai_codex. for spec in std_specs: if model_prefix and normalized_prefix == spec.name: return spec + for spec in std_specs: if any( kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords @@ -365,17 +488,29 @@ def find_gateway( api_key: str | None = None, api_base: str | None = None, ) -> ProviderSpec | None: - """Detect gateway/local provider.""" + """Detect gateway/local provider. + + Priority: + 1. provider_name — if it maps to a gateway/local spec, use it directly. + 2. api_key prefix — e.g. "sk-or-" → OpenRouter. + 3. api_base keyword — e.g. "aihubmix" in URL → AiHubMix. + + A standard provider with a custom api_base (e.g. DeepSeek behind a proxy) + will NOT be mistaken for vLLM — the old fallback is gone. + """ + # 1. Direct match by config key if provider_name: spec = find_by_name(provider_name) if spec and (spec.is_gateway or spec.is_local): return spec + # 2. Auto-detect by api_key prefix / api_base keyword for spec in PROVIDERS: if spec.detect_by_key_prefix and api_key and api_key.startswith(spec.detect_by_key_prefix): return spec if spec.detect_by_base_keyword and api_base and spec.detect_by_base_keyword in api_base: return spec + return None diff --git a/deeptutor/utils/document_validator.py b/deeptutor/utils/document_validator.py index 4493101ce..8424302b9 100644 --- a/deeptutor/utils/document_validator.py +++ b/deeptutor/utils/document_validator.py @@ -106,7 +106,7 @@ def validate_upload_safety( ) # Additional MIME type validation for security - guessed_mime, _ = mimetypes.guess_type(filename.lower()) + guessed_mime, _ = mimetypes.guess_type(filename) if guessed_mime and guessed_mime not in DocumentValidator.ALLOWED_MIME_TYPES: raise ValueError( f"MIME type validation failed: {guessed_mime}. File may be malicious or corrupted." diff --git a/deeptutor/utils/json_parser.py b/deeptutor/utils/json_parser.py index 7c471549d..ce0f1f746 100644 --- a/deeptutor/utils/json_parser.py +++ b/deeptutor/utils/json_parser.py @@ -21,13 +21,11 @@ logger = logging.getLogger(__name__) -_UNSET = object() - def parse_json_response( response: str, logger_instance: logging.Logger | None = None, - fallback: Any = _UNSET, + fallback: Any = None, ) -> Any: """ Safely parse JSON from LLM responses with automatic repair. @@ -40,9 +38,7 @@ def parse_json_response( Args: response: Raw string response from LLM logger_instance: Logger instance for debugging (optional) - fallback: Value to return if all parsing fails. - Pass ``None`` explicitly to get ``None`` on failure; - omit the argument (or leave default) to get ``{}``. + fallback: Value to return if all parsing fails (default: {}) Returns: Parsed JSON object, or fallback value if parsing fails @@ -55,7 +51,7 @@ def parse_json_response( """ log = logger_instance or logger - if fallback is _UNSET: + if fallback is None: fallback = {} # Handle empty response @@ -95,7 +91,7 @@ def parse_json_response( return fallback -def safe_json_loads(data: str, fallback: Any = _UNSET) -> Any: +def safe_json_loads(data: str, fallback: Any = None) -> Any: """ Simple wrapper for safe JSON loading. @@ -106,10 +102,8 @@ def safe_json_loads(data: str, fallback: Any = _UNSET) -> Any: Returns: Parsed JSON or fallback value """ - if fallback is _UNSET: - fallback = {} try: return json.loads(data) except json.JSONDecodeError as e: logger.warning(f"JSON parse error: {e}") - return fallback + return fallback if fallback is not None else {} diff --git a/deeptutor_cli/README.md b/deeptutor_cli/README.md index df1d4f29a..5ed850409 100644 --- a/deeptutor_cli/README.md +++ b/deeptutor_cli/README.md @@ -181,11 +181,11 @@ deeptutor plugin info # 查看详情 deeptutor config show ``` -### `provider` — 提供方认证 / 校验 +### `provider` — OAuth 登录 ```bash -deeptutor provider login openai-codex # 执行 OpenAI Codex OAuth 登录 -deeptutor provider login github-copilot # 校验现有 GitHub Copilot 认证是否可用 +deeptutor provider login openai-codex +deeptutor provider login github-copilot ``` --- diff --git a/deeptutor_cli/bot.py b/deeptutor_cli/bot.py index 787e83490..37c1792e8 100644 --- a/deeptutor_cli/bot.py +++ b/deeptutor_cli/bot.py @@ -22,30 +22,30 @@ def bot_list() -> None: bots = get_tutorbot_manager().list_bots() if not bots: - console.print("[dim]No TutorBots configured.[/]") + console.print("[dim]未配置 TutorBot。[/]") return - table = Table(title="TutorBots") + table = Table(title="TutorBot 列表") table.add_column("ID", style="cyan") - table.add_column("Name") - table.add_column("Status") - table.add_column("Model", style="dim") - table.add_column("Channels", style="dim") + table.add_column("名称") + table.add_column("状态") + table.add_column("模型", style="dim") + table.add_column("渠道", style="dim") for b in bots: - status = "[green]running[/]" if b.get("running") else "[dim]stopped[/]" + status = "[green]运行中[/]" if b.get("running") else "[dim]已停止[/]" table.add_row( b["bot_id"], b.get("name", ""), status, - b.get("model") or "(default)", - ", ".join(b.get("channels", [])) or "-", + b.get("model") or "(默认)", + ", ".join(b.get("channels", [])) or "无", ) console.print(table) @app.command("start") def bot_start( - name: str = typer.Argument(..., help="Bot ID to start."), + name: str = typer.Argument(..., help="要启动的 Bot ID。"), ) -> None: """Start a TutorBot instance.""" from deeptutor.services.tutorbot import get_tutorbot_manager @@ -53,14 +53,14 @@ def bot_start( mgr = get_tutorbot_manager() try: instance = asyncio.get_event_loop().run_until_complete(mgr.start_bot(name)) - console.print(f"[green]Started TutorBot '{instance.config.name}' ({name})[/]") + console.print(f"[green]已启动 TutorBot '{instance.config.name}' ({name})[/]") except RuntimeError as e: - console.print(f"[red]Failed to start: {e}[/]") + console.print(f"[red]启动失败:{e}[/]") raise typer.Exit(1) @app.command("stop") def bot_stop( - name: str = typer.Argument(..., help="Bot ID to stop."), + name: str = typer.Argument(..., help="要停止的 Bot ID。"), ) -> None: """Stop a running TutorBot instance.""" from deeptutor.services.tutorbot import get_tutorbot_manager @@ -68,16 +68,16 @@ def bot_stop( mgr = get_tutorbot_manager() stopped = asyncio.get_event_loop().run_until_complete(mgr.stop_bot(name)) if stopped: - console.print(f"[green]Stopped TutorBot '{name}'[/]") + console.print(f"[green]已停止 TutorBot '{name}'[/]") else: - console.print(f"[yellow]Bot '{name}' not found or not running.[/]") + console.print(f"[yellow]未找到 Bot '{name}' 或其未运行。[/]") @app.command("create") def bot_create( - name: str = typer.Argument(..., help="Bot ID."), - display_name: str = typer.Option("", "--name", "-n", help="Display name."), - persona: str = typer.Option("", "--persona", "-p", help="Persona description."), - model: str = typer.Option("", "--model", "-m", help="Model override."), + name: str = typer.Argument(..., help="Bot ID。"), + display_name: str = typer.Option("", "--name", "-n", help="显示名称。"), + persona: str = typer.Option("", "--persona", "-p", help="人设描述。"), + model: str = typer.Option("", "--model", "-m", help="覆盖模型。"), ) -> None: """Create a new TutorBot configuration and start it.""" from deeptutor.services.tutorbot import get_tutorbot_manager @@ -93,7 +93,7 @@ def bot_create( instance = asyncio.get_event_loop().run_until_complete( mgr.start_bot(name, config) ) - console.print(f"[green]Created and started TutorBot '{instance.config.name}' ({name})[/]") + console.print(f"[green]已创建并启动 TutorBot '{instance.config.name}' ({name})[/]") except RuntimeError as e: - console.print(f"[red]Failed: {e}[/]") + console.print(f"[red]失败:{e}[/]") raise typer.Exit(1) diff --git a/deeptutor_cli/chat.py b/deeptutor_cli/chat.py index 5a6e33208..3de4d6e33 100644 --- a/deeptutor_cli/chat.py +++ b/deeptutor_cli/chat.py @@ -20,7 +20,7 @@ class ChatState: capability: str = "chat" tools: list[str] = field(default_factory=list) knowledge_bases: list[str] = field(default_factory=list) - language: str = "en" + language: str = "zh" notebook_references: list[dict[str, Any]] = field(default_factory=list) history_references: list[str] = field(default_factory=list) config: dict[str, Any] = field(default_factory=dict) @@ -30,15 +30,15 @@ def register(app: typer.Typer) -> None: @app.callback(invoke_without_command=True) def chat( ctx: typer.Context, - session: str | None = typer.Option(None, "--session", help="Resume an existing session."), - tool: list[str] = typer.Option([], "--tool", "-t", help="Pre-enable tool(s)."), - capability: str = typer.Option("chat", "--capability", "-c", help="Initial capability."), - kb: list[str] = typer.Option([], "--kb", help="Pre-attach knowledge base(s)."), - notebook_ref: list[str] = typer.Option([], "--notebook-ref", help="Notebook references."), - history_ref: list[str] = typer.Option([], "--history-ref", help="Referenced session ids."), - language: str = typer.Option("en", "--language", "-l", help="Response language."), + session: str | None = typer.Option(None, "--session", help="继续已有会话。"), + tool: list[str] = typer.Option([], "--tool", "-t", help="预先启用工具。"), + capability: str = typer.Option("chat", "--capability", "-c", help="初始能力。"), + kb: list[str] = typer.Option([], "--kb", help="预关联知识库。"), + notebook_ref: list[str] = typer.Option([], "--notebook-ref", help="笔记本引用。"), + history_ref: list[str] = typer.Option([], "--history-ref", help="引用的会话 ID。"), + language: str = typer.Option("zh", "--language", "-l", help="响应语言。"), ) -> None: - """Enter interactive chat REPL. Use `deeptutor run` for single-turn execution.""" + """进入交互式聊天 REPL。单轮执行请使用 `deeptutor run`。""" if ctx.invoked_subcommand is not None: return @@ -59,7 +59,7 @@ async def _chat_repl(state: ChatState) -> None: if state.session_id: existing = await client.get_session(state.session_id) if existing is None: - console.print(f"[red]Session not found:[/] {state.session_id}") + console.print(f"[red]未找到会话:[/] {state.session_id}") raise typer.Exit(code=1) preferences = existing.get("preferences", {}) or {} state.capability = str(preferences.get("capability") or state.capability or "chat") @@ -76,7 +76,7 @@ async def _chat_repl(state: ChatState) -> None: console.print( Panel( "[bold]DeepTutor CLI[/]\n" - "Type a message to chat. Commands:\n" + "输入消息开始聊天。命令:\n" " /quit /session /new\n" " /tool on|off \n" " /cap \n" @@ -84,14 +84,14 @@ async def _chat_repl(state: ChatState) -> None: " /history add | /history clear\n" " /notebook add | /notebook clear\n" " /refs /config show|set|clear", - title="deeptutor chat", + title="deeptutor 聊天", ) ) _print_state(state) while True: try: - user_input = console.input("[bold green]You>[/] ").strip() + user_input = console.input("[bold green]你>[/] ").strip() except (EOFError, KeyboardInterrupt): console.print() break @@ -125,11 +125,11 @@ def _apply_command(raw: str, state: ChatState) -> bool: if command == "/quit": return False if command == "/session": - console.print(f"session={state.session_id or '(new)'}") + console.print(f"session={state.session_id or '(新建)'}") return True if command == "/new": state.session_id = None - console.print("[dim]Started a new chat context.[/]") + console.print("[dim]已开始新的聊天上下文。[/]") return True if command == "/refs": _print_state(state) @@ -178,14 +178,14 @@ def _apply_command(raw: str, state: ChatState) -> bool: _print_state(state) return True - console.print("[dim]Unknown command.[/]") + console.print("[dim]未知命令。[/]") return True def _print_state(state: ChatState) -> None: console.print( "[dim]" - f"session={state.session_id or '(new)'} " + f"session={state.session_id or '(新建)'} " f"capability={state.capability} " f"tools={state.tools or '[]'} " f"kb={state.knowledge_bases or '[]'} " diff --git a/deeptutor_cli/common.py b/deeptutor_cli/common.py index ad4e94441..fba6ca2bd 100644 --- a/deeptutor_cli/common.py +++ b/deeptutor_cli/common.py @@ -21,7 +21,7 @@ def parse_config_items(items: list[str]) -> dict[str, Any]: for item in items: key, sep, raw_value = item.partition("=") if not sep or not key.strip(): - raise ValueError(f"Invalid --config item `{item}`. Expected KEY=VALUE.") + raise ValueError(f"无效 --config 参数 `{item}`,期望格式 KEY=VALUE。") config[key.strip()] = _parse_scalar_value(raw_value.strip()) return config @@ -32,9 +32,9 @@ def parse_json_object(raw: str | None) -> dict[str, Any]: try: value = json.loads(raw) except json.JSONDecodeError as exc: - raise ValueError(f"Invalid JSON config: {exc.msg}") from exc + raise ValueError(f"无效 JSON 配置:{exc.msg}") from exc if not isinstance(value, dict): - raise ValueError("JSON config must be an object.") + raise ValueError("JSON 配置必须是对象。") return value @@ -44,7 +44,7 @@ def parse_notebook_references(items: list[str]) -> list[dict[str, Any]]: notebook_id, _, record_part = item.partition(":") resolved_notebook_id = notebook_id.strip() if not resolved_notebook_id: - raise ValueError(f"Invalid notebook reference `{item}`.") + raise ValueError(f"无效笔记本引用 `{item}`。") record_ids = [ record_id.strip() for record_id in record_part.split(",") @@ -85,7 +85,7 @@ async def render_turn_stream(*, app: DeepTutorApp, turn_id: str) -> None: console.print(Markdown(content_buf)) content_buf = "" current_stage = str(item.get("stage", "") or "") - console.print(f"\n[bold cyan]▶ {current_stage or 'working'}[/]", highlight=False) + console.print(f"\n[bold cyan]▶ {current_stage or '处理中'}[/]", highlight=False) elif event_type == "stage_end": if content_buf: console.print(Markdown(content_buf)) @@ -106,7 +106,7 @@ async def render_turn_stream(*, app: DeepTutorApp, turn_id: str) -> None: elif event_type == "tool_result": console.print(f" [green]result[/] {item.get('content', '')}", highlight=False) elif event_type == "error": - console.print(f"[bold red]Error:[/] {item.get('content', '')}") + console.print(f"[bold red]错误:[/] {item.get('content', '')}") elif event_type == "done": if content_buf: console.print(Markdown(content_buf)) @@ -146,12 +146,12 @@ def maybe_run(coro): # noqa: ANN001 def print_session_table(sessions: list[dict[str, Any]]) -> None: - table = Table(title="Sessions") + table = Table(title="会话") table.add_column("ID") - table.add_column("Title") - table.add_column("Capability") - table.add_column("Status") - table.add_column("Messages", justify="right") + table.add_column("标题") + table.add_column("能力") + table.add_column("状态") + table.add_column("消息数", justify="right") for session in sessions: table.add_row( str(session.get("id", "")), @@ -164,11 +164,11 @@ def print_session_table(sessions: list[dict[str, Any]]) -> None: def print_notebook_table(notebooks: list[dict[str, Any]]) -> None: - table = Table(title="Notebooks") + table = Table(title="笔记本") table.add_column("ID") - table.add_column("Name") - table.add_column("Records", justify="right") - table.add_column("Description") + table.add_column("名称") + table.add_column("记录数", justify="right") + table.add_column("描述") for notebook in notebooks: table.add_row( str(notebook.get("id", "")), diff --git a/deeptutor_cli/kb.py b/deeptutor_cli/kb.py index 39b73746d..474bd9ef2 100644 --- a/deeptutor_cli/kb.py +++ b/deeptutor_cli/kb.py @@ -42,7 +42,7 @@ def _collect_documents(docs: list[str], docs_dir: Optional[str]) -> list[str]: if docs_dir: base = Path(docs_dir).expanduser().resolve() if not base.exists() or not base.is_dir(): - raise typer.BadParameter(f"docs directory does not exist: {base}") + raise typer.BadParameter(f"文档目录不存在:{base}") for pattern in FileTypeRouter.get_glob_patterns_for_provider(DEFAULT_PROVIDER): candidates.extend(path for path in base.rglob(pattern) if path.is_file()) @@ -61,7 +61,7 @@ def _collect_documents(docs: list[str], docs_dir: Optional[str]) -> list[str]: def register(app: typer.Typer) -> None: @app.command("list") def kb_list( - fmt: str = typer.Option("rich", "--format", "-f", help="Output format: rich | json."), + fmt: str = typer.Option("rich", "--format", "-f", help="输出格式:rich | json。"), ) -> None: """List all knowledge bases.""" mgr = _get_kb_manager() @@ -70,7 +70,7 @@ def kb_list( if fmt == "json": console.print_json("[]") else: - console.print("[dim]No knowledge bases found.[/]") + console.print("[dim]未找到知识库。[/]") return if fmt == "json": @@ -89,12 +89,12 @@ def kb_list( console.print_json(json.dumps(items, ensure_ascii=False, default=str)) return - table = Table(title="Knowledge Bases") - table.add_column("Name", style="bold") - table.add_column("Status") - table.add_column("Documents", justify="right") - table.add_column("RAG Provider") - table.add_column("Default") + table = Table(title="知识库") + table.add_column("名称", style="bold") + table.add_column("状态") + table.add_column("文档数", justify="right") + table.add_column("RAG 提供商") + table.add_column("默认") for name in kb_names: info = mgr.get_info(name) @@ -105,43 +105,43 @@ def kb_list( str(info.get("status", "unknown")), str(stats.get("raw_documents", 0)), str(metadata.get("rag_provider", stats.get("rag_provider", DEFAULT_PROVIDER))), - "yes" if info.get("is_default") else "", + "是" if info.get("is_default") else "", ) console.print(table) @app.command("info") - def kb_info(name: str = typer.Argument(..., help="Knowledge base name.")) -> None: + def kb_info(name: str = typer.Argument(..., help="知识库名称。")) -> None: """Show details of a knowledge base.""" mgr = _get_kb_manager() try: info = mgr.get_info(name) except Exception as exc: - console.print(f"[red]Knowledge base '{name}' not found: {exc}[/]") + console.print(f"[red]未找到知识库 '{name}':{exc}[/]") raise typer.Exit(code=1) from exc console.print_json(json.dumps(info, indent=2, ensure_ascii=False, default=str)) @app.command("set-default") - def kb_set_default(name: str = typer.Argument(..., help="Knowledge base name.")) -> None: + def kb_set_default(name: str = typer.Argument(..., help="知识库名称。")) -> None: """Set the default knowledge base.""" mgr = _get_kb_manager() try: mgr.set_default(name) except Exception as exc: - console.print(f"[red]Failed to set default KB '{name}': {exc}[/]") + console.print(f"[red]设置默认知识库 '{name}' 失败:{exc}[/]") raise typer.Exit(code=1) from exc - console.print(f"[green]Set '{name}' as default knowledge base.[/]") + console.print(f"[green]已将 '{name}' 设为默认知识库。[/]") @app.command("create") def kb_create( - name: str = typer.Argument(..., help="New KB name."), - docs: list[str] = typer.Option([], "--doc", "-d", help="Document paths."), - docs_dir: Optional[str] = typer.Option(None, "--docs-dir", help="Directory of documents."), + name: str = typer.Argument(..., help="新知识库名称。"), + docs: list[str] = typer.Option([], "--doc", "-d", help="文档路径。"), + docs_dir: Optional[str] = typer.Option(None, "--docs-dir", help="文档目录。"), ) -> None: """Initialize a new knowledge base from documents.""" mgr = _get_kb_manager() if name in mgr.list_knowledge_bases(): - console.print(f"[red]Knowledge base '{name}' already exists.[/]") + console.print(f"[red]知识库 '{name}' 已存在。[/]") raise typer.Exit(code=1) try: @@ -151,11 +151,11 @@ def kb_create( raise typer.Exit(code=1) from exc if not doc_paths: - console.print("[red]Provide at least one supported document (--doc or --docs-dir).[/]") + console.print("[red]请至少提供一个支持的文档(--doc 或 --docs-dir)。[/]") raise typer.Exit(code=1) console.print( - f"Creating KB [bold]{name}[/] with {len(doc_paths)} document(s) via [bold]LlamaIndex[/]..." + f"正在通过 [bold]LlamaIndex[/] 创建知识库 [bold]{name}[/],共 {len(doc_paths)} 个文档..." ) from deeptutor.knowledge.initializer import initialize_knowledge_base @@ -169,20 +169,20 @@ def kb_create( ) ) except Exception as exc: - console.print(f"[red]KB creation failed: {exc}[/]") + console.print(f"[red]知识库创建失败:{exc}[/]") raise typer.Exit(code=1) from exc - console.print("[green]Knowledge base created successfully.[/]") + console.print("[green]知识库创建成功。[/]") @app.command("add") def kb_add( - name: str = typer.Argument(..., help="KB name."), - docs: list[str] = typer.Option([], "--doc", "-d", help="Document paths to add."), - docs_dir: Optional[str] = typer.Option(None, "--docs-dir", help="Directory of documents."), + name: str = typer.Argument(..., help="知识库名称。"), + docs: list[str] = typer.Option([], "--doc", "-d", help="要添加的文档路径。"), + docs_dir: Optional[str] = typer.Option(None, "--docs-dir", help="文档目录。"), ) -> None: """Add documents to an existing knowledge base.""" mgr = _get_kb_manager() if name not in mgr.list_knowledge_bases(): - console.print(f"[red]Knowledge base '{name}' not found.[/]") + console.print(f"[red]未找到知识库 '{name}'。[/]") raise typer.Exit(code=1) try: @@ -192,10 +192,10 @@ def kb_add( raise typer.Exit(code=1) from exc if not doc_paths: - console.print("[red]Provide at least one supported document.[/]") + console.print("[red]请至少提供一个支持的文档。[/]") raise typer.Exit(code=1) - console.print(f"Adding {len(doc_paths)} document(s) to [bold]{name}[/]...") + console.print(f"正在向 [bold]{name}[/] 添加 {len(doc_paths)} 个文档...") from deeptutor.knowledge.add_documents import add_documents try: @@ -208,22 +208,22 @@ def kb_add( ) ) except Exception as exc: - console.print(f"[red]Document upload failed: {exc}[/]") + console.print(f"[red]文档上传失败:{exc}[/]") raise typer.Exit(code=1) from exc if processed_count: - console.print(f"[green]Done. Indexed {processed_count} document(s).[/]") + console.print(f"[green]完成,已索引 {processed_count} 个文档。[/]") else: - console.print("[yellow]No new unique documents were indexed.[/]") + console.print("[yellow]没有新的唯一文档被索引。[/]") @app.command("delete") def kb_delete( - name: str = typer.Argument(..., help="KB name."), - force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."), + name: str = typer.Argument(..., help="知识库名称。"), + force: bool = typer.Option(False, "--force", "-f", help="跳过确认。"), ) -> None: """Delete a knowledge base.""" if not force: - confirm = typer.confirm(f"Delete knowledge base '{name}'?") + confirm = typer.confirm(f"确认删除知识库 '{name}' 吗?") if not confirm: raise typer.Abort() @@ -231,27 +231,27 @@ def kb_delete( try: deleted = mgr.delete_knowledge_base(name, confirm=True) except Exception as exc: - console.print(f"[red]Failed to delete '{name}': {exc}[/]") + console.print(f"[red]删除 '{name}' 失败:{exc}[/]") raise typer.Exit(code=1) from exc if deleted: - console.print(f"[green]Deleted '{name}'.[/]") + console.print(f"[green]已删除 '{name}'。[/]") else: - console.print(f"[yellow]Knowledge base '{name}' was not deleted.[/]") + console.print(f"[yellow]知识库 '{name}' 未被删除。[/]") @app.command("search") def kb_search( - name: str = typer.Argument(..., help="KB name."), - query: str = typer.Argument(..., help="Search query."), - mode: str = typer.Option("hybrid", help="Search mode."), - fmt: str = typer.Option("rich", "--format", "-f", help="Output format: rich | json."), + name: str = typer.Argument(..., help="知识库名称。"), + query: str = typer.Argument(..., help="搜索查询。"), + mode: str = typer.Option("hybrid", help="搜索模式。"), + fmt: str = typer.Option("rich", "--format", "-f", help="输出格式:rich | json。"), ) -> None: """Search a knowledge base.""" from deeptutor.tools.rag_tool import rag_search mgr = _get_kb_manager() if name not in mgr.list_knowledge_bases(): - console.print(f"[red]Knowledge base '{name}' not found.[/]") + console.print(f"[red]未找到知识库 '{name}'。[/]") raise typer.Exit(code=1) try: @@ -264,7 +264,7 @@ def kb_search( ) ) except Exception as exc: - console.print(f"[red]Search failed: {exc}[/]") + console.print(f"[red]搜索失败:{exc}[/]") raise typer.Exit(code=1) from exc if fmt == "json": @@ -273,5 +273,5 @@ def kb_search( answer = result.get("answer") or result.get("content", "") provider = result.get("provider", DEFAULT_PROVIDER) - console.print(f"[bold]Provider:[/] {provider}") - console.print(f"[bold]Answer:[/]\n{answer}") + console.print(f"[bold]提供商:[/] {provider}") + console.print(f"[bold]答案:[/]\n{answer}") diff --git a/deeptutor_cli/main.py b/deeptutor_cli/main.py index 392de636e..38bb04fa0 100644 --- a/deeptutor_cli/main.py +++ b/deeptutor_cli/main.py @@ -21,20 +21,20 @@ app = typer.Typer( name="deeptutor", - help="DeepTutor CLI – agent-first interface for capabilities, tools, and knowledge.", + help="DeepTutor CLI - 面向智能体的能力、工具与知识统一入口。", no_args_is_help=True, add_completion=False, ) -bot_app = typer.Typer(help="Manage TutorBot instances.") -chat_app = typer.Typer(help="Interactive chat REPL.") -kb_app = typer.Typer(help="Manage knowledge bases.") -memory_app = typer.Typer(help="View and manage lightweight memory.") -plugin_app = typer.Typer(help="List plugins.") -config_app = typer.Typer(help="Inspect configuration.") -session_app = typer.Typer(help="Manage shared sessions.") -notebook_app = typer.Typer(help="Manage notebooks and imported markdown records.") -provider_app = typer.Typer(help="Manage provider OAuth login.") +bot_app = typer.Typer(help="管理 TutorBot 实例。") +chat_app = typer.Typer(help="交互式聊天 REPL。") +kb_app = typer.Typer(help="管理知识库。") +memory_app = typer.Typer(help="查看和管理轻量记忆。") +plugin_app = typer.Typer(help="查看插件。") +config_app = typer.Typer(help="查看配置。") +session_app = typer.Typer(help="管理共享会话。") +notebook_app = typer.Typer(help="管理笔记本与导入的 Markdown 记录。") +provider_app = typer.Typer(help="管理 Provider OAuth 登录。") app.add_typer(bot_app, name="bot") app.add_typer(chat_app, name="chat") @@ -59,19 +59,19 @@ @app.command("run") def run_capability( - capability: str = typer.Argument(..., help="Capability name (e.g. chat, deep_solve, deep_question, deep_research, math_animator)."), - message: str = typer.Argument(..., help="Message to send."), - session: str | None = typer.Option(None, "--session", help="Existing session id."), - tool: list[str] = typer.Option([], "--tool", "-t", help="Enabled tool(s)."), - kb: list[str] = typer.Option([], "--kb", help="Knowledge base name."), - notebook_ref: list[str] = typer.Option([], "--notebook-ref", help="Notebook references."), - history_ref: list[str] = typer.Option([], "--history-ref", help="Referenced session ids."), - language: str = typer.Option("en", "--language", "-l", help="Response language."), - config: list[str] = typer.Option([], "--config", help="Capability config key=value."), - config_json: str | None = typer.Option(None, "--config-json", help="Capability config as JSON."), - fmt: str = typer.Option("rich", "--format", "-f", help="Output format: rich | json."), + capability: str = typer.Argument(..., help="能力名称(如 chat、deep_solve、deep_question、deep_research、math_animator)。"), + message: str = typer.Argument(..., help="要发送的消息。"), + session: str | None = typer.Option(None, "--session", help="已有会话 ID。"), + tool: list[str] = typer.Option([], "--tool", "-t", help="启用的工具。"), + kb: list[str] = typer.Option([], "--kb", help="知识库名称。"), + notebook_ref: list[str] = typer.Option([], "--notebook-ref", help="笔记本引用。"), + history_ref: list[str] = typer.Option([], "--history-ref", help="引用的会话 ID。"), + language: str = typer.Option("zh", "--language", "-l", help="响应语言。"), + config: list[str] = typer.Option([], "--config", help="能力配置 key=value。"), + config_json: str | None = typer.Option(None, "--config-json", help="JSON 格式能力配置。"), + fmt: str = typer.Option("rich", "--format", "-f", help="输出格式:rich | json。"), ) -> None: - """Run any capability in a single turn (agent-first entry point).""" + """单轮执行任意能力(智能体优先入口)。""" from deeptutor.app import DeepTutorApp from .common import run_turn_and_render @@ -92,28 +92,18 @@ def run_capability( @app.command() def serve( - host: str = typer.Option("0.0.0.0", help="Bind address."), - port: int = typer.Option(get_backend_port(), help="Port number."), - reload: bool = typer.Option(False, help="Enable auto-reload for development."), + host: str = typer.Option("0.0.0.0", help="绑定地址。"), + port: int = typer.Option(get_backend_port(), help="端口号。"), + reload: bool = typer.Option(False, help="开发模式启用自动重载。"), ) -> None: - """Start the DeepTutor API server.""" - import asyncio - import sys - + """启动 DeepTutor API 服务。""" set_mode(RunMode.SERVER) - - # Windows: uvicorn defaults to SelectorEventLoop which does not support - # asyncio.create_subprocess_exec. Switch to ProactorEventLoop so that - # child-process APIs (used by Math Animator renderer, etc.) work correctly. - if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - try: import uvicorn except ImportError: console.print( "[bold red]Error:[/] API server dependencies not installed.\n" - "Run: pip install -r requirements/server.txt" + "请运行:pip install -r requirements/server.txt" ) raise typer.Exit(code=1) diff --git a/deeptutor_cli/memory.py b/deeptutor_cli/memory.py index 3eff771cb..4395153f7 100644 --- a/deeptutor_cli/memory.py +++ b/deeptutor_cli/memory.py @@ -18,7 +18,7 @@ def register(app: typer.Typer) -> None: @app.command("show") def memory_show( file: str = typer.Argument( - "all", help="File to show: summary, profile, or all.", + "all", help="要显示的文件:summary、profile 或 all。", ), ) -> None: """Display memory file content.""" @@ -32,37 +32,37 @@ def memory_show( if content: console.print(Panel(Markdown(content), title=f"[bold]{label}.md[/]")) else: - console.print(f"[dim]{label}.md: (empty)[/]") + console.print(f"[dim]{label}.md: (空)[/]") elif file in ("summary", "profile"): content = svc.read_file(file) if content: console.print(Panel(Markdown(content), title=f"[bold]{file.upper()}.md[/]")) else: - console.print(f"[dim]{file.upper()}.md: (empty)[/]") + console.print(f"[dim]{file.upper()}.md: (空)[/]") else: - console.print(f"[red]Unknown file: {file}. Use summary, profile, or all.[/]") + console.print(f"[red]未知文件:{file}。请使用 summary、profile 或 all。[/]") @app.command("clear") def memory_clear( file: str = typer.Argument( - "all", help="File to clear: summary, profile, or all.", + "all", help="要清空的文件:summary、profile 或 all。", ), - force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."), + force: bool = typer.Option(False, "--force", "-f", help="跳过确认。"), ) -> None: """Clear memory file(s).""" svc = get_memory_service() if file not in ("summary", "profile", "all"): - console.print(f"[red]Unknown file: {file}[/]") + console.print(f"[red]未知文件:{file}[/]") raise typer.Exit(1) if not force: - target = "all memory files" if file == "all" else f"{file.upper()}.md" - if not typer.confirm(f"Clear {target}?"): + target = "所有记忆文件" if file == "all" else f"{file.upper()}.md" + if not typer.confirm(f"确认清空 {target} 吗?"): raise typer.Abort() if file == "all": svc.clear_memory() - console.print("[green]Cleared all memory files.[/]") + console.print("[green]已清空所有记忆文件。[/]") else: svc.clear_file(file) - console.print(f"[green]Cleared {file.upper()}.md.[/]") + console.print(f"[green]已清空 {file.upper()}.md。[/]") diff --git a/deeptutor_cli/notebook.py b/deeptutor_cli/notebook.py index 0a2724d80..30ab73259 100644 --- a/deeptutor_cli/notebook.py +++ b/deeptutor_cli/notebook.py @@ -20,8 +20,8 @@ def list_notebooks() -> None: @app.command("create") def create_notebook( - name: str = typer.Argument(..., help="Notebook name."), - description: str = typer.Option("", "--description", help="Notebook description."), + name: str = typer.Argument(..., help="笔记本名称。"), + description: str = typer.Option("", "--description", help="笔记本描述。"), ) -> None: """Create a notebook.""" client = DeepTutorApp() @@ -30,14 +30,14 @@ def create_notebook( @app.command("show") def show_notebook( - notebook_id: str = typer.Argument(..., help="Notebook id."), - fmt: str = typer.Option("rich", "--format", help="Output format: rich | json."), + notebook_id: str = typer.Argument(..., help="笔记本 ID。"), + fmt: str = typer.Option("rich", "--format", help="输出格式:rich | json。"), ) -> None: """Show a notebook and its records.""" client = DeepTutorApp() notebook = client.get_notebook(notebook_id) if notebook is None: - console.print(f"[red]Notebook not found:[/] {notebook_id}") + console.print(f"[red]未找到笔记本:[/] {notebook_id}") raise typer.Exit(code=1) if fmt == "json": console.print(json.dumps(notebook, ensure_ascii=False, indent=2, default=str)) @@ -53,8 +53,8 @@ def show_notebook( @app.command("add-md") def add_md( - notebook_id: str = typer.Argument(..., help="Notebook id."), - path: str = typer.Argument(..., help="Path to markdown file."), + notebook_id: str = typer.Argument(..., help="笔记本 ID。"), + path: str = typer.Argument(..., help="Markdown 文件路径。"), ) -> None: """Import a markdown file as a co-writer notebook record.""" client = DeepTutorApp() @@ -63,9 +63,9 @@ def add_md( @app.command("replace-md") def replace_md( - notebook_id: str = typer.Argument(..., help="Notebook id."), - record_id: str = typer.Argument(..., help="Existing record id."), - path: str = typer.Argument(..., help="Path to markdown file."), + notebook_id: str = typer.Argument(..., help="笔记本 ID。"), + record_id: str = typer.Argument(..., help="已有记录 ID。"), + path: str = typer.Argument(..., help="Markdown 文件路径。"), ) -> None: """Replace an existing co-writer notebook record in-place.""" client = DeepTutorApp() @@ -74,13 +74,13 @@ def replace_md( @app.command("remove-record") def remove_record( - notebook_id: str = typer.Argument(..., help="Notebook id."), - record_id: str = typer.Argument(..., help="Record id."), + notebook_id: str = typer.Argument(..., help="笔记本 ID。"), + record_id: str = typer.Argument(..., help="记录 ID。"), ) -> None: """Delete a notebook record.""" client = DeepTutorApp() success = client.remove_record(notebook_id, record_id) if not success: - console.print(f"[red]Record not found:[/] {record_id}") + console.print(f"[red]未找到记录:[/] {record_id}") raise typer.Exit(code=1) - console.print(f"Removed record {record_id} from notebook {notebook_id}") + console.print(f"已从笔记本 {notebook_id} 删除记录 {record_id}") diff --git a/deeptutor_cli/plugin.py b/deeptutor_cli/plugin.py index 9814c27a6..4b4349378 100644 --- a/deeptutor_cli/plugin.py +++ b/deeptutor_cli/plugin.py @@ -27,21 +27,21 @@ def plugin_list() -> None: tr = get_tool_registry() cr = get_capability_registry() - table = Table(title="Registered Plugins") - table.add_column("Name", style="bold") - table.add_column("Type") - table.add_column("Description") + table = Table(title="已注册插件") + table.add_column("名称", style="bold") + table.add_column("类型") + table.add_column("描述") for defn in tr.get_definitions(): - table.add_row(defn.name, "tool", defn.description[:80]) + table.add_row(defn.name, "工具", defn.description[:80]) for m in cr.get_manifests(): - table.add_row(m["name"], "capability", m["description"][:80]) + table.add_row(m["name"], "能力", m["description"][:80]) console.print(table) @app.command("info") - def plugin_info(name: str = typer.Argument(..., help="Tool or capability name.")) -> None: + def plugin_info(name: str = typer.Argument(..., help="工具或能力名称。")) -> None: """Show details of a tool or capability.""" import json @@ -68,5 +68,5 @@ def plugin_info(name: str = typer.Argument(..., help="Tool or capability name.") }, indent=2)) return - console.print(f"[red]'{name}' not found.[/]") + console.print(f"[red]未找到 '{name}'。[/]") raise typer.Exit(code=1) diff --git a/deeptutor_cli/provider_cmd.py b/deeptutor_cli/provider_cmd.py index 29f3dc294..0884189bf 100644 --- a/deeptutor_cli/provider_cmd.py +++ b/deeptutor_cli/provider_cmd.py @@ -1,4 +1,4 @@ -"""CLI commands for provider auth and access validation.""" +"""CLI commands for provider auth (OAuth-first providers).""" from __future__ import annotations @@ -12,10 +12,10 @@ def register(app: typer.Typer) -> None: def provider_login( provider: str = typer.Argument( ..., - help="Provider: openai-codex (OAuth login) | github-copilot (validate existing Copilot auth)", + help="OAuth 提供商:openai-codex | github-copilot", ), ) -> None: - """Authenticate or validate provider access.""" + """Authenticate an OAuth-backed provider.""" key = provider.strip().lower().replace("-", "_") if key == "openai_codex": _login_openai_codex() @@ -24,7 +24,7 @@ def provider_login( maybe_run(_login_github_copilot()) return raise typer.BadParameter( - f"Unknown provider `{provider}`. Supported: openai-codex, github-copilot" + f"未知提供商 `{provider}`。支持:openai-codex、github-copilot" ) @@ -33,7 +33,7 @@ def _login_openai_codex() -> None: from oauth_cli_kit import get_token, login_oauth_interactive except ImportError: typer.echo( - "oauth_cli_kit is not installed. Install CLI deps: " + "未安装 oauth_cli_kit。请安装 CLI 依赖:" "pip install -r requirements/cli.txt" ) raise typer.Exit(code=1) @@ -49,30 +49,24 @@ def _login_openai_codex() -> None: prompt_fn=typer.prompt, ) if not (token and getattr(token, "access", None)): - typer.echo("OpenAI Codex OAuth authentication failed.") + typer.echo("OpenAI Codex OAuth 认证失败。") raise typer.Exit(code=1) - typer.echo("OpenAI Codex OAuth authentication succeeded.") + typer.echo("OpenAI Codex OAuth 认证成功。") async def _login_github_copilot() -> None: - """Validate an existing GitHub Copilot auth session via a lightweight request.""" try: - from openai import AsyncOpenAI + from litellm import acompletion except ImportError: - typer.echo("openai is not installed. Install CLI deps: pip install -r requirements/cli.txt") + typer.echo("未安装 litellm。请安装 CLI 依赖:pip install -r requirements/cli.txt") raise typer.Exit(code=1) try: - client = AsyncOpenAI( - api_key="copilot", - base_url="https://api.githubcopilot.com", - max_retries=0, - ) - await client.chat.completions.create( - model="gpt-4o", + await acompletion( + model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "ping"}], max_tokens=1, ) except Exception as exc: - typer.echo(f"GitHub Copilot auth validation failed: {exc}") + typer.echo(f"GitHub Copilot OAuth 认证失败:{exc}") raise typer.Exit(code=1) from exc - typer.echo("GitHub Copilot auth validation succeeded.") + typer.echo("GitHub Copilot OAuth 认证成功。") diff --git a/deeptutor_cli/session_cmd.py b/deeptutor_cli/session_cmd.py index 06ee2df92..cef94c628 100644 --- a/deeptutor_cli/session_cmd.py +++ b/deeptutor_cli/session_cmd.py @@ -15,37 +15,37 @@ def register(app: typer.Typer) -> None: @app.command("list") def list_sessions( - limit: int = typer.Option(20, "--limit", help="Maximum sessions to show."), + limit: int = typer.Option(20, "--limit", help="最多显示会话数。"), ) -> None: """List existing sessions.""" maybe_run(_list_sessions(limit)) @app.command("show") def show_session( - session_id: str = typer.Argument(..., help="Session id."), - fmt: str = typer.Option("rich", "--format", help="Output format: rich | json."), + session_id: str = typer.Argument(..., help="会话 ID。"), + fmt: str = typer.Option("rich", "--format", help="输出格式:rich | json。"), ) -> None: """Show a session and its persisted messages.""" maybe_run(_show_session(session_id, fmt)) @app.command("open") def open_session( - session_id: str = typer.Argument(..., help="Session id."), + session_id: str = typer.Argument(..., help="会话 ID。"), ) -> None: """Enter the interactive chat REPL with an existing session.""" maybe_run(_chat_repl(ChatState(session_id=session_id))) @app.command("delete") def delete_session( - session_id: str = typer.Argument(..., help="Session id."), + session_id: str = typer.Argument(..., help="会话 ID。"), ) -> None: """Delete a session and all of its turns/messages.""" maybe_run(_delete_session(session_id)) @app.command("rename") def rename_session( - session_id: str = typer.Argument(..., help="Session id."), - title: str = typer.Option(..., "--title", help="New session title."), + session_id: str = typer.Argument(..., help="会话 ID。"), + title: str = typer.Option(..., "--title", help="新的会话标题。"), ) -> None: """Rename a session.""" maybe_run(_rename_session(session_id, title)) @@ -61,7 +61,7 @@ async def _show_session(session_id: str, fmt: str) -> None: client = DeepTutorApp() session = await client.get_session(session_id) if session is None: - console.print(f"[red]Session not found:[/] {session_id}") + console.print(f"[red]未找到会话:[/] {session_id}") raise typer.Exit(code=1) if fmt == "json": @@ -89,13 +89,13 @@ async def _delete_session(session_id: str) -> None: if not success: console.print(f"[red]Session not found:[/] {session_id}") raise typer.Exit(code=1) - console.print(f"Deleted session {session_id}") + console.print(f"已删除会话 {session_id}") async def _rename_session(session_id: str, title: str) -> None: client = DeepTutorApp() success = await client.rename_session(session_id, title) if not success: - console.print(f"[red]Session not found:[/] {session_id}") + console.print(f"[red]未找到会话:[/] {session_id}") raise typer.Exit(code=1) - console.print(f"Renamed {session_id} -> {title}") + console.print(f"已重命名 {session_id} -> {title}") diff --git a/pyproject.toml b/pyproject.toml index 1b608155e..e093984a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "aiohttp>=3.9.4", "httpx>=0.27.0", "requests>=2.32.2", + "litellm>=1.79.0", "ddgs>=9.9.1", "nest_asyncio>=1.5.8", "tenacity>=8.0.0", diff --git a/requirements/cli.txt b/requirements/cli.txt index dd0b84489..f1627d704 100644 --- a/requirements/cli.txt +++ b/requirements/cli.txt @@ -11,6 +11,7 @@ jinja2>=3.1.0 # --- LLM --- openai>=1.30.0 +litellm>=1.79.0 tiktoken>=0.5.0 # --- LLM provider SDKs --- @@ -43,6 +44,6 @@ arxiv>=2.0.0 ddgs>=9.9.1 # --- CLI framework --- -typer>=0.9.0 +typer[all]>=0.9.0 rich>=13.0.0 prompt_toolkit>=3.0.36 diff --git a/scripts/start_tour.py b/scripts/start_tour.py index 250741dcb..312b8dfcf 100644 --- a/scripts/start_tour.py +++ b/scripts/start_tour.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import locale import os from pathlib import Path import platform @@ -18,39 +17,6 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -# --------------------------------------------------------------------------- -# Bootstrap: ensure minimum packages required to import project modules -# --------------------------------------------------------------------------- - -_BOOTSTRAP_PACKAGES = [ - ("yaml", "PyYAML>=6.0"), -] - - -def _bootstrap() -> None: - missing = [pip for imp, pip in _BOOTSTRAP_PACKAGES if not _can_import(imp)] - if not missing: - return - print(f" Installing bootstrap dependencies: {', '.join(missing)} ...") - subprocess.check_call( - [sys.executable, "-m", "pip", "install", *missing, "-q"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - -def _can_import(name: str) -> bool: - try: - __import__(name) - return True - except ImportError: - return False - - -_bootstrap() - -# --------------------------------------------------------------------------- - def _load_runtime_deps(): from _cli_kit import ( @@ -224,58 +190,15 @@ def _missing_math_animator_system_deps() -> list[str]: def _math_animator_install_cmd(dep: str) -> list[str] | None: system = platform.system().lower() - _INSTALL_MAPS: dict[str, dict[str, list[str]]] = { - "brew": { + if system == "darwin" and shutil.which("brew"): + mapping = { "latex": ["brew", "install", "--cask", "basictex"], "pkg-config": ["brew", "install", "pkgconf"], "cmake": ["brew", "install", "cmake"], "ffmpeg": ["brew", "install", "ffmpeg"], "cairo": ["brew", "install", "cairo"], - }, - "apt": { - "latex": ["sudo", "apt", "install", "-y", "texlive-latex-base"], - "pkg-config": ["sudo", "apt", "install", "-y", "pkg-config"], - "cmake": ["sudo", "apt", "install", "-y", "cmake"], - "ffmpeg": ["sudo", "apt", "install", "-y", "ffmpeg"], - "cairo": ["sudo", "apt", "install", "-y", "libcairo2-dev"], - }, - "dnf": { - "latex": ["sudo", "dnf", "install", "-y", "texlive-scheme-basic"], - "pkg-config": ["sudo", "dnf", "install", "-y", "pkgconf"], - "cmake": ["sudo", "dnf", "install", "-y", "cmake"], - "ffmpeg": ["sudo", "dnf", "install", "-y", "ffmpeg"], - "cairo": ["sudo", "dnf", "install", "-y", "cairo-devel"], - }, - "yum": { - "latex": ["sudo", "yum", "install", "-y", "texlive-latex"], - "pkg-config": ["sudo", "yum", "install", "-y", "pkgconfig"], - "cmake": ["sudo", "yum", "install", "-y", "cmake"], - "ffmpeg": ["sudo", "yum", "install", "-y", "ffmpeg"], - "cairo": ["sudo", "yum", "install", "-y", "cairo-devel"], - }, - "winget": { - "latex": ["winget", "install", "--id", "MiKTeX.MiKTeX", "-e"], - "cmake": ["winget", "install", "--id", "Kitware.CMake", "-e"], - "ffmpeg": ["winget", "install", "--id", "Gyan.FFmpeg", "-e"], - }, - "choco": { - "latex": ["choco", "install", "miktex", "-y"], - "pkg-config": ["choco", "install", "pkgconfiglite", "-y"], - "cmake": ["choco", "install", "cmake", "-y"], - "ffmpeg": ["choco", "install", "ffmpeg", "-y"], - "cairo": ["choco", "install", "gtk-runtime", "-y"], - }, - } - if system == "darwin" and shutil.which("brew"): - return _INSTALL_MAPS["brew"].get(dep) - if system == "linux": - for pm in ("apt", "dnf", "yum"): - if shutil.which(pm): - return _INSTALL_MAPS[pm].get(dep) - if system == "windows": - for pm in ("winget", "choco"): - if shutil.which(pm): - return _INSTALL_MAPS[pm].get(dep) + } + return mapping.get(dep) return None @@ -520,31 +443,15 @@ def _tour_banner() -> None: # Web path — install deps, start temp server, wait for browser config # =================================================================== -def _stream_text_kwargs() -> dict[str, object]: - """Best-effort text decoding for background process output.""" - # Some Windows child processes emit locale-specific bytes even when Python - # runs in UTF-8 mode. Replace undecodable bytes so the background drain - # thread keeps consuming output instead of crashing. - encoding = locale.getpreferredencoding(False) or "utf-8" - return { - "stdout": subprocess.PIPE, - "stderr": subprocess.STDOUT, - "text": True, - "encoding": encoding, - "errors": "replace", - "bufsize": 1, - } - - def _spawn_process( cmd: list[str], *, cwd: Path, env: dict[str, str], name: str, ) -> subprocess.Popen[str]: import threading kwargs: dict[str, object] = { - "cwd": str(cwd), - "env": env, - **_stream_text_kwargs(), + "cwd": str(cwd), "env": env, + "stdout": subprocess.PIPE, "stderr": subprocess.STDOUT, + "text": True, "bufsize": 1, } if os.name == "nt": kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] diff --git a/start.md b/start.md new file mode 100644 index 000000000..db5700dd8 --- /dev/null +++ b/start.md @@ -0,0 +1,12 @@ +# 1) 进入项目根目录 +cd /Users/shichangwei/Desktop/goal/DeepTutor-1.0.0-beta.2 + +# 2) 激活虚拟环境(如未激活) +source .venv/bin/activate + +# 3) 启动后端(终端A) +python3 -m deeptutor.api.run_server + +# 4) 启动前端(终端B) +cd web +npm run dev diff --git "a/submission/01-\347\211\210\346\234\254\345\267\256\345\274\202\350\257\264\346\230\216.md" "b/submission/01-\347\211\210\346\234\254\345\267\256\345\274\202\350\257\264\346\230\216.md" new file mode 100644 index 000000000..cfa31f28f --- /dev/null +++ "b/submission/01-\347\211\210\346\234\254\345\267\256\345\274\202\350\257\264\346\230\216.md" @@ -0,0 +1,64 @@ +# 版本差异说明(上游 DeepTutor vs 本衍生版本) + +更新日期:2026-04-12 + +## 1. 上游项目当前状态(用于对齐基线) + +基于上游仓库 `HKUDS/DeepTutor` 页面公开信息(2026-04-12 采样): + +- 最新 Release:`v1.0.2`(发布日期:2026-04-06) +- Stars:约 `8.6k` +- Forks:约 `1.1k` +- Open Issues:`6` +- Open PRs:`4` + +参考: +- https://github.com/HKUDS/DeepTutor +- https://github.com/HKUDS/DeepTutor/releases + +## 2. 本项目基线与分支定位 + +- 本衍生版本技术基线来自 `DeepTutor-1.0.0-beta.2` 代码线,并进行了二次开发。 +- 分支目标不是替代上游,而是作为教育场景衍生发行版: + - 学生学习闭环 + - 备考提分场景 + - 双语/英文可切换交付 + +## 3. 与上游差异(高层) + +### 3.1 产品层差异 + +- 新增/强化了目标导向学习链路:`Goal` + `Exam Radar` + `Day/Interactive`。 +- 强化了学习过程可视化与追踪:学习任务卡、阶段状态、反馈触发重排。 +- 对学习助手自我认知进行了教学场景化约束(更偏课程与考试辅导)。 + +### 3.2 前端与交互差异 + +- 进行了系统级本地化改造(简中与英文版产物分离)。 +- 页面文案、占位符、按钮、提示语统一梳理,减少混语展示。 +- `Co-Writer` 示例模板重写为英文教学写作示例(英文化副本)。 + +### 3.3 配置与品牌差异 + +- 将定制品牌统一回收为 `DeepTutor`(英文化提交副本)。 +- 同步修正 API 标题、自我认知 prompt、前端品牌显示与相关键值。 + +## 4. 兼容性与风险评估 + +### 4.1 兼容性 + +- 保留上游核心模块结构与调用关系(`deeptutor/` + `web/` 主体未重构)。 +- 构建链路可通过(前端 `npm run -s build` 已验证)。 + +### 4.2 主要风险 + +- 由于加入备考增强路径,和上游 `main` 持续演进存在长期漂移风险。 +- 若上游后续在 `Goal/Guide/Prompt` 层做大改,需维护 rebase 适配计划。 + +## 5. 建议给上游维护者的审查视角 + +- 建议按“可拆分合并”审查: + 1. 品牌与文案规范化 + 2. i18n 与英文化补齐 + 3. 学生/备考功能增强 +- 对于场景化增强,建议以 feature flag 或独立 capability 开关形式讨论合并路径。 diff --git "a/submission/02-\344\272\214\346\254\241\345\274\200\345\217\221\345\256\232\344\275\215\344\270\216\350\214\203\345\233\264.md" "b/submission/02-\344\272\214\346\254\241\345\274\200\345\217\221\345\256\232\344\275\215\344\270\216\350\214\203\345\233\264.md" new file mode 100644 index 000000000..5363c3357 --- /dev/null +++ "b/submission/02-\344\272\214\346\254\241\345\274\200\345\217\221\345\256\232\344\275\215\344\270\216\350\214\203\345\233\264.md" @@ -0,0 +1,48 @@ +# 二次开发定位与范围 + +## 1. 项目定位 + +本项目定位为 **DeepTutor 的衍生定制版本(Derivative Edition)**,目标用户为: + +- 在校学生 +- 需要阶段性备考提分的学习者 +- 需要“学习计划 + 练习闭环 + 复盘追踪”的用户 + +核心价值主张: + +- 保持 DeepTutor 的通用智能体能力 +- 强化面向学习/考试的任务编排与反馈闭环 +- 提供更贴近教学过程的前端交互与文案体验 + +## 2. 功能范围(本次二开) + +### 2.1 学习与备考导向能力 + +- Goal 驱动学习任务生成 +- Day-level 学习任务执行与反馈 +- Exam Radar 真题频次分析与高频点提取 +- 任务反馈触发重排(replan) + +### 2.2 体验与产品化改造 + +- 全站文案梳理(简中版本) +- 英文化副本输出(用于上游审核/国际化校验) +- 品牌回归 `DeepTutor` 与 AI 自我认知统一 + +### 2.3 提交与协作准备 + +- 形成回归测试清单 +- 形成版本差异说明与 PR 草案 +- 形成标准提交结构(便于上游 maintainer 快速审查) + +## 3. 非目标(避免范围蔓延) + +- 不重写上游核心 Agent 架构 +- 不改变协议层与核心持久化范式 +- 不把衍生版强行并入上游默认体验(应保持可开关) + +## 4. 后续建议 + +- 引入 feature flag(如 `exam_mode_enabled`)减少与上游差异耦合。 +- 将教育场景增强抽象为 capability 套件,便于上游选择性吸收。 +- 建立“与上游同步窗口”(按 release 周期 rebase/merge)。 diff --git "a/submission/03-\345\233\236\345\275\222\346\265\213\350\257\225\346\270\205\345\215\225.md" "b/submission/03-\345\233\236\345\275\222\346\265\213\350\257\225\346\270\205\345\215\225.md" new file mode 100644 index 000000000..2fdf9aa74 --- /dev/null +++ "b/submission/03-\345\233\236\345\275\222\346\265\213\350\257\225\346\270\205\345\215\225.md" @@ -0,0 +1,73 @@ +# 回归测试清单(提交审核用) + +执行日期:____ +执行人:____ +环境:`OS / Python / Node / Browser` + +## A. 构建与启动 + +- [ ] 后端依赖安装成功(`pip install -e .`) +- [ ] 前端依赖安装成功(`npm ci`) +- [ ] 前端构建成功(`web: npm run -s build`) +- [ ] 本地联调可启动(`docker-compose` 或本地分别启动) + +## B. 核心页面可达性 + +- [ ] `/` Chat 首页可正常渲染 +- [ ] `/goal` 可打开并加载配置 +- [ ] `/exam-radar` 可打开并提交分析输入 +- [ ] `/guide` 可打开并显示学习上下文 +- [ ] `/knowledge`、`/memory`、`/settings` 可正常打开 +- [ ] `/co-writer` 编辑器与预览正常 +- [ ] `/agents` 与 `/agents/[botId]/chat` 可进入 + +## C. 学习/备考链路回归 + +- [ ] Goal:可创建 session,并返回阶段进度 +- [ ] Goal:任务卡可渲染,状态变化正常 +- [ ] Day 页面:反馈提交成功 +- [ ] Day 页面:可触发重排(replan) +- [ ] Exam Radar:文本输入可分析,结果面板可展示 +- [ ] Exam Radar:文件上传后可统计题型/考点 + +## D. 对话与智能体行为 + +- [ ] 普通 Chat 请求可得到响应 +- [ ] 工具调用链(thinking/acting/observing)无报错 +- [ ] TutorBot 聊天发送/流式返回正常 +- [ ] Notebook 保存流程(含摘要流)可完成 + +## E. 本地化与品牌一致性 + +- [ ] 英文化副本默认语言为 `en` +- [ ] 英文化副本 UI 无明显中文裸露(除显式 i18n key 路径外) +- [ ] 品牌名统一为 `DeepTutor`(前端/后端/prompt) +- [ ] API 欢迎信息与页面 metadata 已对齐 `DeepTutor` + +## F. 数据与兼容性 + +- [ ] 旧会话读取不崩溃 +- [ ] 新建会话、重命名、删除会话正常 +- [ ] Memory 刷新、保存、清空流程正常 +- [ ] Knowledge Base 基础管理操作无回归 + +## G. 负向与边界测试 + +- [ ] 空输入提交提示符合预期 +- [ ] WebSocket 中断时有可解释提示 +- [ ] 后端 4xx/5xx 错误可被前端安全处理 +- [ ] 长文本输入不会导致页面明显卡死 + +## H. 提交前门禁(建议) + +- [ ] 代码扫描无高危明文密钥 +- [ ] `node_modules`、运行时数据目录未进入提交内容 +- [ ] 文档齐全(差异说明/定位说明/测试清单/PR 草案) +- [ ] 提交说明明确“衍生版本定位 + 可拆分审查策略” + +## I. 结果记录 + +- 通过项:____ +- 未通过项:____ +- 阻塞问题:____ +- 结论:`可提交审核 / 需修复后提交` diff --git "a/submission/04-\346\240\207\345\207\206\346\217\220\344\272\244\347\273\223\346\236\204.md" "b/submission/04-\346\240\207\345\207\206\346\217\220\344\272\244\347\273\223\346\236\204.md" new file mode 100644 index 000000000..9659f531c --- /dev/null +++ "b/submission/04-\346\240\207\345\207\206\346\217\220\344\272\244\347\273\223\346\236\204.md" @@ -0,0 +1,58 @@ +# 标准提交结构(建议) + +> 目标:让上游 maintainer 最短时间理解“你改了什么、为什么改、有没有风险、如何验证”。 + +## 1. 仓库目录建议 + +建议在仓库内保留如下结构: + +```text +submission/ + README.md + 01-版本差异说明.md + 02-二次开发定位与范围.md + 03-回归测试清单.md + 04-标准提交结构.md + 05-PR说明草案.md +``` + +## 2. Git 提交拆分建议(按可审查性) + +1. `chore(brand): normalize product name to DeepTutor` +2. `feat(i18n): complete zh-CN localization and en variant cleanup` +3. `feat(edu): goal/exam-radar learning workflow enhancements` +4. `docs(submission): add regression checklist and derivative positioning docs` + +## 3. PR 审查顺序建议 + +1. 品牌与文案一致性(低风险) +2. i18n 与英文化(中风险) +3. 学生/备考能力增强(中高风险) +4. 文档与回归结果(审查收口) + +## 4. 提交包附带信息(必须) + +- 变更动机(为什么不是上游已有能力) +- 影响范围(前端/后端/prompt/配置) +- 风险与回滚方案 +- 已执行回归测试项及结果 + +## 5. 双版本交付建议 + +当前你已有双目录: + +- 简中定制主线:`/Users/shichangwei/Desktop/goal/TYUT X_V1` +- 英文化审核副本:`/Users/shichangwei/Desktop/goal/DeepTutor-en-from-current` + +建议: + +- 对外审核优先使用英文化副本(便于国际维护者审查) +- 简中版作为衍生发行主线保留 + +## 6. 回滚策略模板 + +- 若上游不接受“场景增强”改动: + - 保留 `brand + i18n` PR + - 将 `goal/exam-radar` 作为独立扩展分支维护 +- 若上游仅接受部分能力: + - 按 capability 维度拆解成小 PR 逐步合并 diff --git "a/submission/05-PR\350\257\264\346\230\216\350\215\211\346\241\210.md" "b/submission/05-PR\350\257\264\346\230\216\350\215\211\346\241\210.md" new file mode 100644 index 000000000..4d0e94334 --- /dev/null +++ "b/submission/05-PR\350\257\264\346\230\216\350\215\211\346\241\210.md" @@ -0,0 +1,61 @@ +# PR 说明草案(可直接改名后提交) + +## Title + +Derivative Edition for Student & Exam-Prep Scenarios: branding normalization, i18n cleanup, and learning workflow enhancements + +## Summary + +This PR proposes a **DeepTutor derivative edition** focused on student learning and exam-prep workflows, while preserving upstream core architecture compatibility. + +## Positioning + +- This is **not** a replacement of upstream DeepTutor. +- This is a scenario-focused derivative distribution for: + - students + - users with exam-prep needs + - users requiring plan-practice-review loops + +## What Changed + +1. Branding normalization +- Unified product naming back to `DeepTutor` across API metadata, prompts, and UI-facing strings. + +2. i18n and language cleanup +- Completed zh-CN localization chain for the localized branch. +- Produced an English-facing review branch for upstream maintainer review. +- Reduced mixed-language leakage in key user-facing paths. + +3. Student/Exam-prep workflow enhancements +- Goal-oriented planning and day-level execution flow. +- Exam Radar for high-frequency point extraction from exam materials. +- Feedback-driven replanning loop. + +## Why + +- Upstream provides strong general capabilities; this derivative focuses on education-specific operational workflow. +- Improves practical usability for learning progression and score-oriented preparation. + +## Risk Assessment + +- Low risk: branding and text consistency +- Medium risk: i18n and prompt behavior alignment +- Medium-high risk: scenario workflow additions (`goal`/`exam-radar`) + +## Validation + +- Frontend build passes (`npm run -s build`). +- Regression checklist attached in `submission/03-回归测试清单.md`. + +## Review Strategy (Recommended) + +Please review in this order: +1. Branding normalization +2. i18n cleanup +3. Student/Exam-prep workflow enhancements + +## References + +- Upstream repo: https://github.com/HKUDS/DeepTutor +- Upstream releases: https://github.com/HKUDS/DeepTutor/releases +- Submission docs: `submission/` diff --git "a/submission/06-\345\217\257\347\233\264\346\216\245\347\262\230\350\264\264PR\346\217\217\350\277\260.md" "b/submission/06-\345\217\257\347\233\264\346\216\245\347\262\230\350\264\264PR\346\217\217\350\277\260.md" new file mode 100644 index 000000000..b6494e38d --- /dev/null +++ "b/submission/06-\345\217\257\347\233\264\346\216\245\347\262\230\350\264\264PR\346\217\217\350\277\260.md" @@ -0,0 +1,100 @@ +# PR: DeepTutor Derivative Edition for Student & Exam-Prep Workflows + +## Summary + +This PR introduces a **DeepTutor derivative edition** focused on student learning and exam-prep scenarios, while keeping upstream core architecture compatibility. + +This is **not a replacement** of upstream DeepTutor. It is a scenario-oriented derivative distribution with education-specific workflow enhancements. + +## Positioning + +- Product family: **DeepTutor Derivative Edition** +- Target users: + - Students + - Users with exam-prep requirements + - Users needing plan-practice-review loops +- Design principle: + - Keep upstream-compatible core + - Add education-focused workflow layers + +## Upstream Baseline (as of 2026-04-12) + +- Upstream repo: https://github.com/HKUDS/DeepTutor +- Latest release: `v1.0.2` (2026-04-06) +- Public status snapshot used for alignment: + - ~8.6k stars + - ~1.1k forks + - 6 open issues + - 4 open PRs + +## What Changed + +### 1. Branding normalization + +- Unified product naming back to `DeepTutor` in API metadata, prompts, and UI-facing strings. +- Aligned assistant self-identification with DeepTutor naming. + +### 2. i18n / language cleanup + +- Completed localization cleanup for localized branch. +- Produced an English-facing review branch for maintainers. +- Reduced mixed-language leakage in key UX flows. + +### 3. Student & exam-prep workflow enhancements + +- Goal-oriented planning flow. +- Day-level execution and feedback loop. +- Exam Radar for high-frequency point extraction from exam materials. +- Feedback-driven replanning behavior. + +## Why + +- Upstream DeepTutor is strong at general intelligence workflows. +- This derivative focuses on practical education operations: + - task planning + - focused practice + - review feedback loops + - exam-oriented prioritization + +## Risk & Compatibility + +- Low risk: + - branding / wording consistency changes +- Medium risk: + - i18n and prompt behavior alignment +- Medium-high risk: + - workflow additions in goal/exam-radar paths + +Compatibility notes: +- Core structure preserved (`deeptutor/` + `web/` main architecture unchanged). +- Frontend production build validated successfully. + +## Validation + +- Frontend build passes: `web: npm run -s build` +- Regression checklist attached: + - `submission/03-回归测试清单.md` + +## Suggested Review Order + +1. Branding normalization +2. i18n / language cleanup +3. Student & exam-prep workflow enhancements +4. Regression checklist and docs + +## Attached Docs + +- `submission/01-版本差异说明.md` +- `submission/02-二次开发定位与范围.md` +- `submission/03-回归测试清单.md` +- `submission/04-标准提交结构.md` + +## Optional Merge Strategy + +If full scope is too broad for one PR, split into: + +1. `chore(brand): normalize product naming to DeepTutor` +2. `feat(i18n): language cleanup and review branch readiness` +3. `feat(edu): goal/exam-radar student workflow enhancements` +4. `docs: submission pack and regression checklist` + diff --git "a/submission/07-\346\234\200\345\260\217\346\217\220\344\272\244\346\265\201\347\250\213.md" "b/submission/07-\346\234\200\345\260\217\346\217\220\344\272\244\346\265\201\347\250\213.md" new file mode 100644 index 000000000..5950c5f97 --- /dev/null +++ "b/submission/07-\346\234\200\345\260\217\346\217\220\344\272\244\346\265\201\347\250\213.md" @@ -0,0 +1,49 @@ +# 最小提交流程(你现在就能执行) + +## 1) 选择提交源目录 + +建议优先用英文化审核目录: + +- `/Users/shichangwei/Desktop/goal/DeepTutor-en-from-current` + +## 2) 在 GitHub 新建仓库(你的衍生版) + +仓库名建议: + +- `deeptutor-student-edition` + +## 3) 首次推送 + +```bash +cd /Users/shichangwei/Desktop/goal/DeepTutor-en-from-current +# 初始化(如果当前目录不是 git 仓库) +git init + +git add . +git commit -m "chore: prepare DeepTutor derivative submission pack" + +git branch -M main +git remote add origin +git push -u origin main +``` + +## 4) 发起给上游的沟通方式 + +如果你没有直接向上游提交代码权限,建议: + +- 先在上游 `HKUDS/DeepTutor` 开 `Discussion` 或 `Issue` +- 附上你仓库地址 + `submission/06-可直接粘贴PR描述.md` +- 说明你愿意按 maintainer 建议拆分 PR + +## 5) 提交时附带文件(必须) + +- `submission/06-可直接粘贴PR描述.md` +- `submission/01-版本差异说明.md` +- `submission/02-二次开发定位与范围.md` +- `submission/03-回归测试清单.md` + +## 6) 沟通关键词(建议原话) + +- "This is a derivative edition focused on student and exam-prep workflows." +- "We preserve upstream architecture compatibility and support split-review PR strategy." +- "Branding/i18n/workflow enhancements can be reviewed and merged independently." diff --git a/submission/README.md b/submission/README.md new file mode 100644 index 000000000..04af46a1b --- /dev/null +++ b/submission/README.md @@ -0,0 +1,21 @@ +# DeepTutor Derivative Submission Pack + +本目录用于提交给 DeepTutor 上游维护者审核,定位为: + +- DeepTutor 的衍生版本(Derivative Distribution) +- 面向学生群体与备考需求群体的定制化分支 +- 保持上游核心架构兼容,聚焦教学与考试场景增强 + +## 文档清单 + +1. `01-版本差异说明.md` +2. `02-二次开发定位与范围.md` +3. `03-回归测试清单.md` +4. `04-标准提交结构.md` +5. `05-PR说明草案.md` + +## 建议提交方式 + +- 在 PR 描述中附上:`01` + `02` + `03` +- 在 PR 附件/仓库目录中附上:`04` + `05` +- 若使用双仓提交(简中版/英文化版),可在 `05` 中按模板替换仓库地址 diff --git a/tests/api/test_goal_router.py b/tests/api/test_goal_router.py new file mode 100644 index 000000000..7d6657257 --- /dev/null +++ b/tests/api/test_goal_router.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import importlib + +import pytest + +pytest.importorskip("fastapi") + +FastAPI = pytest.importorskip("fastapi").FastAPI +TestClient = pytest.importorskip("fastapi.testclient").TestClient +router_module = importlib.import_module("deeptutor.api.routers.goal") + + +def _build_app() -> FastAPI: + app = FastAPI() + app.include_router(router_module.router, prefix="/api/v1/goal") + return app + + +def test_create_session_route(monkeypatch) -> None: + class FakeOrchestrator: + async def create_session(self, kb_name, goal_config): + return type("Session", (), {"session_id": "goal_20260409_001", "status": "created"})() + + fake_orchestrator = FakeOrchestrator() + monkeypatch.setattr(router_module, "get_orchestrator", lambda: fake_orchestrator) + + with TestClient(_build_app()) as client: + response = client.post( + "/api/v1/goal/create_session", + json={ + "kb_name": "placeholder_kb", + "goal_config": {"goal_level": "foundation", "daily_minutes": 90}, + }, + ) + + assert response.status_code == 200 + assert response.json()["data"]["session_id"] == "goal_20260409_001" + + +def test_replan_route_returns_diff(monkeypatch) -> None: + class FakeDiff: + def model_dump(self, mode="json"): + return {"added_tasks": ["task_2"], "moved_tasks": [], "dropped_tasks": []} + + class FakePlan: + plan_version = 2 + diff = FakeDiff() + + class FakeOrchestrator: + async def replan(self, session_id, reason): + return FakePlan() + + fake_orchestrator = FakeOrchestrator() + monkeypatch.setattr(router_module, "get_orchestrator", lambda: fake_orchestrator) + + with TestClient(_build_app()) as client: + response = client.post( + "/api/v1/goal/session/goal_001/replan", + json={"reason": "low_accuracy", "strategy": "rule_based"}, + ) + + assert response.status_code == 200 + assert response.json()["data"]["plan_version"] == 2 + assert response.json()["data"]["diff"]["added_tasks"] == ["task_2"] + + +def test_submit_feedback_route_accepts_payload(monkeypatch) -> None: + captured = {} + + class FakeOrchestrator: + async def submit_feedback(self, session_id, feedback): + captured["session_id"] = session_id + captured["task_id"] = feedback.task_id + return {"accepted": True, "next_action": "none"} + + fake_orchestrator = FakeOrchestrator() + monkeypatch.setattr(router_module, "get_orchestrator", lambda: fake_orchestrator) + + with TestClient(_build_app()) as client: + response = client.post( + "/api/v1/goal/session/goal_001/feedback", + json={ + "feedback_id": "fb_001", + "task_id": "task_day1_001", + "completion": "done", + "actual_minutes": 35, + "quiz": {"score": 4, "total": 5}, + "reflection": "掌握不错", + }, + ) + + assert response.status_code == 200 + assert response.json()["data"]["accepted"] is True + assert captured == {"session_id": "goal_001", "task_id": "task_day1_001"} + + +def test_get_plan_returns_plan_not_ready_error_when_missing(monkeypatch) -> None: + class FakeStorage: + def load_plan(self, session_id): + raise FileNotFoundError(session_id) + + class FakeOrchestrator: + storage = FakeStorage() + + fake_orchestrator = FakeOrchestrator() + monkeypatch.setattr(router_module, "get_orchestrator", lambda: fake_orchestrator) + + with TestClient(_build_app()) as client: + response = client.get("/api/v1/goal/session/goal_404/plan") + + assert response.status_code == 400 + payload = response.json()["detail"] + assert payload["ok"] is False + assert payload["error"]["code"] == "PLAN_NOT_READY" + + +def test_run_plan_returns_plan_failed_error(monkeypatch) -> None: + class FakeOrchestrator: + async def run_plan(self, session_id): + raise ValueError("planner returned empty task list") + + monkeypatch.setattr(router_module, "get_orchestrator", lambda: FakeOrchestrator()) + + with TestClient(_build_app()) as client: + response = client.post("/api/v1/goal/session/goal_001/run_plan") + + assert response.status_code == 400 + payload = response.json()["detail"] + assert payload["ok"] is False + assert payload["error"]["code"] == "PLAN_FAILED" + + +def test_generate_practice_returns_task_not_found_error(monkeypatch) -> None: + class FakeOrchestrator: + async def generate_practice(self, session_id, task_id, count=3): + raise ValueError("Task not found") + + monkeypatch.setattr(router_module, "get_orchestrator", lambda: FakeOrchestrator()) + + with TestClient(_build_app()) as client: + response = client.post( + "/api/v1/goal/session/goal_001/task/task_missing/generate_practice", + json={"count": 3, "difficulty": "medium", "question_type": "choice"}, + ) + + assert response.status_code == 400 + payload = response.json()["detail"] + assert payload["ok"] is False + assert payload["error"]["code"] == "TASK_NOT_FOUND" + + +def test_get_day_plan_detail_route_success(monkeypatch) -> None: + class FakeStorage: + def load_session(self, session_id): + return type("Session", (), {"session_id": session_id})() + + class FakeDayDetail: + def model_dump(self, mode="json"): + return { + "session_id": "goal_001", + "day_index": 1, + "date": "2026-04-10", + "objective_summary": "完成当日核心任务", + "time_blocks": [], + "key_points": [], + "pitfalls": [], + "acceptance_criteria": [], + "review_actions": [], + "linked_task_ids": [], + } + + class FakeOrchestrator: + storage = FakeStorage() + + def get_day_plan_detail(self, session_id, day_index): + return FakeDayDetail() + + monkeypatch.setattr(router_module, "get_orchestrator", lambda: FakeOrchestrator()) + + with TestClient(_build_app()) as client: + response = client.get("/api/v1/goal/session/goal_001/day/1") + + assert response.status_code == 200 + assert response.json()["data"]["day_index"] == 1 + assert response.json()["data"]["objective_summary"] == "完成当日核心任务" + + +def test_get_day_plan_detail_route_returns_day_not_found(monkeypatch) -> None: + class FakeStorage: + def load_session(self, session_id): + return type("Session", (), {"session_id": session_id})() + + class FakeOrchestrator: + storage = FakeStorage() + + def get_day_plan_detail(self, session_id, day_index): + raise ValueError(f"Day not found: {day_index}") + + monkeypatch.setattr(router_module, "get_orchestrator", lambda: FakeOrchestrator()) + + with TestClient(_build_app()) as client: + response = client.get("/api/v1/goal/session/goal_001/day/99") + + assert response.status_code == 400 + payload = response.json()["detail"] + assert payload["ok"] is False + assert payload["error"]["code"] == "DAY_NOT_FOUND" + + +def test_goal_router_end_to_end_flow(monkeypatch) -> None: + class FakeDiff: + def model_dump(self, mode="json"): + return {"added_tasks": ["task_day2_review_001"], "moved_tasks": [], "dropped_tasks": []} + + class FakePlan: + def __init__(self, version: int): + self.plan_id = "plan_001" + self.plan_version = version + self.status = "ready" + self.diff = FakeDiff() if version > 1 else None + + def model_dump(self, mode="json", by_alias=True): + return { + "plan_id": self.plan_id, + "plan_version": self.plan_version, + "status": self.status, + "days": [{"day_index": 1, "budget_minutes": 90, "task_ids": ["task_day1_001"], "notes": ""}], + "tasks": [ + { + "task_id": "task_day1_001", + "day_index": 1, + "node_id": "node_limit", + "title": "Learn limit basics", + "kind": "learn", + "estimate_minutes": 45, + "objective": "Understand limits", + } + ], + } + + class FakeSession: + def __init__(self): + self.session_id = "goal_20260409_001" + self.status = "created" + self.plan_version = 0 + + class FakeStorage: + def __init__(self, session: FakeSession, plan: FakePlan): + self._session = session + self._plan = plan + + def load_session(self, session_id): + return self._session + + def load_plan(self, session_id): + return self._plan + + class FakeOrchestrator: + def __init__(self): + self._session = FakeSession() + self._plan = FakePlan(version=1) + self.storage = FakeStorage(self._session, self._plan) + + async def create_session(self, kb_name, goal_config): + self._session.status = "created" + self._session.plan_version = 0 + return self._session + + async def run_plan(self, session_id): + self._session.status = "ready" + self._session.plan_version = 1 + self._plan = FakePlan(version=1) + self.storage._plan = self._plan + return self._plan + + async def submit_feedback(self, session_id, feedback): + return {"accepted": True, "next_action": "none"} + + async def replan(self, session_id, reason): + self._session.plan_version = 2 + self._plan = FakePlan(version=2) + self.storage._plan = self._plan + return self._plan + + async def generate_practice(self, session_id, task_id, count=3): + return { + "practice_set_path": f"data/user/goal/{session_id}/practice/{task_id}.json", + "practice_set": { + "questions": [{"question": "What is a limit?", "reference_answer": "A value approached"}] + }, + } + + fake_orchestrator = FakeOrchestrator() + monkeypatch.setattr(router_module, "get_orchestrator", lambda: fake_orchestrator) + + with TestClient(_build_app()) as client: + created = client.post( + "/api/v1/goal/create_session", + json={ + "kb_name": "placeholder_kb", + "goal_config": {"goal_level": "foundation", "daily_minutes": 90}, + }, + ) + session_id = created.json()["data"]["session_id"] + + run_result = client.post(f"/api/v1/goal/session/{session_id}/run_plan") + session = client.get(f"/api/v1/goal/session/{session_id}") + plan = client.get(f"/api/v1/goal/session/{session_id}/plan") + feedback = client.post( + f"/api/v1/goal/session/{session_id}/feedback", + json={ + "feedback_id": "fb_001", + "task_id": "task_day1_001", + "completion": "partial", + "quiz": {"score": 2, "total": 5}, + }, + ) + replanned = client.post( + f"/api/v1/goal/session/{session_id}/replan", + json={"reason": "manual_feedback", "strategy": "rule_based"}, + ) + practice = client.post( + f"/api/v1/goal/session/{session_id}/task/task_day1_001/generate_practice", + json={"count": 1, "difficulty": "medium", "question_type": "choice"}, + ) + + assert created.status_code == 200 + assert run_result.status_code == 200 + assert session.status_code == 200 + assert plan.status_code == 200 + assert feedback.status_code == 200 + assert replanned.status_code == 200 + assert practice.status_code == 200 + + assert run_result.json()["data"]["plan_version"] == 1 + assert session.json()["data"]["plan_version"] == 1 + assert plan.json()["data"]["plan_version"] == 1 + assert feedback.json()["data"]["accepted"] is True + assert replanned.json()["data"]["plan_version"] == 2 + assert replanned.json()["data"]["diff"]["added_tasks"] == ["task_day2_review_001"] + assert practice.json()["data"]["practice_set_path"].endswith("task_day1_001.json") diff --git a/tests/api/test_goal_ws.py b/tests/api/test_goal_ws.py new file mode 100644 index 000000000..58ecc717a --- /dev/null +++ b/tests/api/test_goal_ws.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import importlib +import json +from pathlib import Path + +import pytest + +pytest.importorskip("fastapi") + +FastAPI = pytest.importorskip("fastapi").FastAPI +TestClient = pytest.importorskip("fastapi.testclient").TestClient +router_module = importlib.import_module("deeptutor.api.routers.goal") + + +def _build_app() -> FastAPI: + app = FastAPI() + app.include_router(router_module.router, prefix="/api/v1/goal") + return app + + +def test_goal_ws_run_plan_emits_plan_and_complete(monkeypatch) -> None: + class FakePlan: + def model_dump(self, mode="json", by_alias=True): + return {"plan_id": "plan_001", "plan_version": 1} + + class FakeStorage: + def get_session_dir(self, session_id): + from pathlib import Path + + return Path("/tmp") / session_id + + def load_plan(self, session_id): + return FakePlan() + + class FakeOrchestrator: + storage = FakeStorage() + + async def run_plan(self, session_id): + return FakePlan() + + monkeypatch.setattr(router_module, "get_orchestrator", lambda: FakeOrchestrator()) + + with TestClient(_build_app()) as client: + with client.websocket_connect("/api/v1/goal/ws/goal_001") as websocket: + websocket.send_json({"type": "run_plan"}) + first = websocket.receive_json() + second = websocket.receive_json() + + assert first["type"] == "plan" + assert first["data"]["plan_id"] == "plan_001" + assert second["type"] == "complete" + + +def test_goal_ws_run_plan_emits_stage_events_before_plan(monkeypatch, tmp_path: Path) -> None: + class FakePlan: + def model_dump(self, mode="json", by_alias=True): + return {"plan_id": "plan_001", "plan_version": 1} + + class FakeStorage: + def get_session_dir(self, session_id): + session_dir = tmp_path / session_id + session_dir.mkdir(parents=True, exist_ok=True) + event_file = session_dir / "events.jsonl" + event_file.write_text( + "\n".join( + [ + json.dumps({"type": "stage", "stage": "extract", "progress": 0.35}), + json.dumps({"type": "stage", "stage": "graph", "progress": 0.7}), + ] + ), + encoding="utf-8", + ) + return session_dir + + def load_plan(self, session_id): + return FakePlan() + + class FakeOrchestrator: + storage = FakeStorage() + + async def run_plan(self, session_id): + return FakePlan() + + monkeypatch.setattr(router_module, "get_orchestrator", lambda: FakeOrchestrator()) + + with TestClient(_build_app()) as client: + with client.websocket_connect("/api/v1/goal/ws/goal_001") as websocket: + websocket.send_json({"type": "run_plan"}) + first = websocket.receive_json() + second = websocket.receive_json() + third = websocket.receive_json() + fourth = websocket.receive_json() + + assert first["type"] == "stage" + assert first["stage"] == "extract" + assert second["type"] == "stage" + assert second["stage"] == "graph" + assert third["type"] == "plan" + assert fourth["type"] == "complete" + + +def test_goal_ws_run_plan_emits_error_when_planner_raises(monkeypatch) -> None: + class FakeStorage: + def get_session_dir(self, session_id): + return Path("/tmp") / session_id + + class FakeOrchestrator: + storage = FakeStorage() + + async def run_plan(self, session_id): + raise RuntimeError("planner exploded") + + monkeypatch.setattr(router_module, "get_orchestrator", lambda: FakeOrchestrator()) + + with TestClient(_build_app()) as client: + with client.websocket_connect("/api/v1/goal/ws/goal_001") as websocket: + websocket.send_json({"type": "run_plan"}) + payload = websocket.receive_json() + + assert payload["type"] == "error" + assert payload["code"] == "PLAN_FAILED" + assert "planner exploded" in payload["content"] diff --git a/tests/api/test_memory_router.py b/tests/api/test_memory_router.py index a0dc40982..4edb29ef1 100644 --- a/tests/api/test_memory_router.py +++ b/tests/api/test_memory_router.py @@ -17,31 +17,18 @@ def _build_app() -> FastAPI: return app -def _make_snapshot( - summary: str = "", - profile: str = "", - summary_updated_at: str | None = None, - profile_updated_at: str | None = None, -): - return type( - "Snapshot", - (), - { - "summary": summary, - "profile": profile, - "summary_updated_at": summary_updated_at, - "profile_updated_at": profile_updated_at, - }, - )() - - def test_memory_router_returns_single_document(monkeypatch) -> None: class FakeMemoryService: def read_snapshot(self): - return _make_snapshot( - profile="## Preferences\n- Prefer concise answers.", - profile_updated_at="2026-03-13T12:00:00+08:00", - ) + return type( + "Snapshot", + (), + { + "content": "## Preferences\n- Prefer concise answers.", + "exists": True, + "updated_at": "2026-03-13T12:00:00+08:00", + }, + )() monkeypatch.setattr("deeptutor.api.routers.memory.get_memory_service", lambda: FakeMemoryService()) @@ -49,11 +36,11 @@ def read_snapshot(self): response = client.get("/api/v1/memory") assert response.status_code == 200 - body = response.json() - assert body["profile"] == "## Preferences\n- Prefer concise answers." - assert body["profile_updated_at"] == "2026-03-13T12:00:00+08:00" - assert body["summary"] == "" - assert body["summary_updated_at"] is None + assert response.json() == { + "content": "## Preferences\n- Prefer concise answers.", + "exists": True, + "updated_at": "2026-03-13T12:00:00+08:00", + } def test_memory_router_refreshes_from_session(monkeypatch) -> None: @@ -63,19 +50,17 @@ async def get_session(self, session_id: str): return None return {"session_id": session_id} - _snapshot = _make_snapshot( - profile="## Preferences\n- Prefer concise answers.", - summary="## Current Focus\n- Working on memory.", - profile_updated_at="2026-03-13T12:10:00+08:00", - summary_updated_at="2026-03-13T12:10:00+08:00", - ) - class FakeMemoryService: async def refresh_from_session(self, session_id, language="en"): - return type("Result", (), {"changed": True})() - - def read_snapshot(self): - return _snapshot + return type( + "Result", + (), + { + "content": "## Preferences\n- Prefer concise answers.\n\n## Context\n- Working on memory.", + "changed": True, + "updated_at": "2026-03-13T12:10:00+08:00", + }, + )() monkeypatch.setattr("deeptutor.api.routers.memory.get_sqlite_session_store", lambda: FakeStore()) monkeypatch.setattr("deeptutor.api.routers.memory.get_memory_service", lambda: FakeMemoryService()) @@ -87,29 +72,32 @@ def read_snapshot(self): ) assert response.status_code == 200 - body = response.json() - assert body["changed"] is True - assert "Working on memory" in body["summary"] - assert "Prefer concise answers" in body["profile"] + assert response.json()["changed"] is True + assert response.json()["exists"] is True + assert "## Context" in response.json()["content"] def test_memory_router_updates_document(monkeypatch) -> None: class FakeMemoryService: - def write_file(self, which, content: str): - return _make_snapshot( - profile=content if which == "profile" else "", - profile_updated_at="2026-03-13T12:20:00+08:00", - ) + def write_memory(self, content: str): + return type( + "Snapshot", + (), + { + "content": content, + "exists": bool(content), + "updated_at": "2026-03-13T12:20:00+08:00", + }, + )() monkeypatch.setattr("deeptutor.api.routers.memory.get_memory_service", lambda: FakeMemoryService()) with TestClient(_build_app()) as client: response = client.put( "/api/v1/memory", - json={"file": "profile", "content": "## Preferences\n- Prefer concise answers."}, + json={"content": "## Preferences\n- Prefer concise answers."}, ) assert response.status_code == 200 - body = response.json() - assert body["saved"] is True - assert body["profile"] == "## Preferences\n- Prefer concise answers." + assert response.json()["saved"] is True + assert response.json()["content"] == "## Preferences\n- Prefer concise answers." diff --git a/tests/cli/test_provider_cli.py b/tests/cli/test_provider_cli.py deleted file mode 100644 index 0b40aa6c8..000000000 --- a/tests/cli/test_provider_cli.py +++ /dev/null @@ -1,33 +0,0 @@ -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -PROVIDER_CMD = (ROOT / "deeptutor_cli" / "provider_cmd.py").read_text(encoding="utf-8") -CLI_README = (ROOT / "deeptutor_cli" / "README.md").read_text(encoding="utf-8") -ROOT_README = (ROOT / "README.md").read_text(encoding="utf-8") - - -class ProviderCliDocsContractTest(unittest.TestCase): - def test_provider_contract_describes_copilot_as_validation_not_oauth_login(self) -> None: - self.assertIn( - 'help="Provider: openai-codex (OAuth login) | github-copilot (validate existing Copilot auth)"', - PROVIDER_CMD, - ) - self.assertIn('"""Authenticate or validate provider access."""', PROVIDER_CMD) - self.assertIn('GitHub Copilot auth validation succeeded.', PROVIDER_CMD) - self.assertIn('GitHub Copilot auth validation failed:', PROVIDER_CMD) - self.assertNotIn('OAuth provider: openai-codex | github-copilot', PROVIDER_CMD) - self.assertNotIn('GitHub Copilot OAuth authentication succeeded.', PROVIDER_CMD) - - def test_readmes_match_the_cli_contract(self) -> None: - self.assertIn( - 'Provider auth (`openai-codex` OAuth login; `github-copilot` validates an existing Copilot auth session)', - ROOT_README, - ) - self.assertIn('deeptutor provider login github-copilot # 校验现有 GitHub Copilot 认证是否可用', CLI_README) - self.assertNotIn('OAuth login (`openai-codex`, `github-copilot`)', ROOT_README) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/goal/test_exercise_adapter.py b/tests/goal/test_exercise_adapter.py new file mode 100644 index 000000000..8c57300bf --- /dev/null +++ b/tests/goal/test_exercise_adapter.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from deeptutor.agents.goal.exercise_adapter import ExerciseAdapter +from deeptutor.agents.goal.models import Task + + +def _build_task() -> Task: + return Task.model_validate( + { + "task_id": "task_001", + "day_index": 1, + "node_id": "node_limit", + "title": "学习极限基础", + "kind": "practice", + "objective": "理解极限定义并完成基础题", + "estimate_minutes": 30, + "practice_spec": { + "difficulty": "easy", + "question_type": "short_answer", + "count": 2, + }, + } + ) + + +def test_exercise_adapter_falls_back_to_local_payload(tmp_path: Path, monkeypatch) -> None: + adapter = ExerciseAdapter() + task = _build_task() + + async def _broken_generate_payload(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(adapter, "_generate_payload", _broken_generate_payload) + + # Since the public method delegates to _generate_payload, call fallback directly for a stable assertion. + payload = adapter._build_fallback_payload(task, "placeholder_kb") + target = tmp_path / "task_001.json" + target.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + saved = json.loads(target.read_text(encoding="utf-8")) + assert saved["source"] == "fallback" + assert saved["questions"][0]["question_type"] == "short_answer" diff --git a/tests/goal/test_extractor.py b/tests/goal/test_extractor.py new file mode 100644 index 000000000..774f8bc72 --- /dev/null +++ b/tests/goal/test_extractor.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from deeptutor.agents.goal.extractor import KnowledgeExtractor + + +@pytest.mark.asyncio +async def test_extractor_uses_fallback_when_kb_missing(tmp_path: Path) -> None: + extractor = KnowledgeExtractor(kb_base_dir=tmp_path / "knowledge_bases") + + drafts = await extractor.extract( + "missing_kb", + scope={"chapters": ["极限", "导数"], "topics": [], "exclude_topics": []}, + ) + + assert [draft.title for draft in drafts[:2]] == ["极限", "导数"] + assert drafts[0].evidence[0].source == "fallback:missing_kb" + assert drafts[1].prerequisites == ["极限"] + + +@pytest.mark.asyncio +async def test_extractor_reads_files_and_dedupes_titles(tmp_path: Path) -> None: + kb_dir = tmp_path / "knowledge_bases" / "calc" + (kb_dir / "content_list").mkdir(parents=True) + (kb_dir / "raw").mkdir(parents=True) + (kb_dir / "content_list" / "chapter1.md").write_text( + "# 极限\n## 导数\n", + encoding="utf-8", + ) + (kb_dir / "raw" / "notes.txt").write_text( + "1. 极限\n导数\n", + encoding="utf-8", + ) + + extractor = KnowledgeExtractor(kb_base_dir=tmp_path / "knowledge_bases") + drafts = await extractor.extract("calc") + by_title = {draft.title: draft for draft in drafts} + + assert "极限" in by_title + assert "导数" in by_title + assert by_title["极限"].doc_freq >= 2.0 + assert len(by_title["极限"].evidence) >= 2 + + +@pytest.mark.asyncio +async def test_extractor_fallback_uses_goal_statement_domain_priors(tmp_path: Path) -> None: + extractor = KnowledgeExtractor(kb_base_dir=tmp_path / "knowledge_bases") + drafts = await extractor.extract( + "missing_kb", + scope={ + "goal_statement": "7天复习微积分并结合历年真题提升解题速度", + "chapters": [], + "topics": [], + "exclude_topics": [], + }, + ) + titles = [draft.title for draft in drafts] + + assert "导数与求导规则" in titles + assert "高频真题题型拆解" in titles diff --git a/tests/goal/test_graph_builder.py b/tests/goal/test_graph_builder.py new file mode 100644 index 000000000..73ee113c2 --- /dev/null +++ b/tests/goal/test_graph_builder.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from deeptutor.agents.goal.graph_builder import GraphBuilder +from deeptutor.agents.goal.models import KnowledgeNodeDraft, WeightEvidence + + +def test_graph_builder_builds_nodes_and_edges() -> None: + builder = GraphBuilder() + drafts = [ + KnowledgeNodeDraft( + title="极限", + evidence=[WeightEvidence(source="doc.md", snippet="极限基础")], + tags=["基础"], + ), + KnowledgeNodeDraft( + title="导数", + prerequisites=["极限"], + evidence=[WeightEvidence(source="doc.md", snippet="导数依赖极限")], + ), + ] + + nodes, edges = builder.build(drafts) + + assert len(nodes) == 2 + assert nodes[0].node_id == "node_极限" + assert nodes[1].node_id == "node_导数" + assert nodes[1].prerequisites == ["node_极限"] + + edge_pairs = {(edge.from_, edge.to, edge.edge_type.value) for edge in edges} + assert ("node_极限", "node_导数", "related") in edge_pairs + assert ("node_极限", "node_导数", "prerequisite") in edge_pairs diff --git a/tests/goal/test_models.py b/tests/goal/test_models.py new file mode 100644 index 000000000..cbc02c0b8 --- /dev/null +++ b/tests/goal/test_models.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import pytest + +from deeptutor.agents.goal.models import GoalConfig, GoalLevel, GoalSession, Plan + + +def test_goal_config_requires_positive_daily_minutes() -> None: + with pytest.raises(ValueError): + GoalConfig(goal_level=GoalLevel.FOUNDATION, daily_minutes=0) + + +def test_goal_config_json_schema_exposes_required_fields() -> None: + schema = GoalConfig.model_json_schema() + assert "goal_level" in schema["required"] + assert "daily_minutes" in schema["required"] + + +def test_plan_requires_days_and_tasks() -> None: + with pytest.raises(ValueError): + Plan.model_validate( + { + "plan_id": "plan_001", + "session_id": "goal_001", + "kb_name": "calc", + "plan_version": 1, + "days": None, + "tasks": [], + "artifacts": { + "root_dir": "data/user/goal/goal_001", + }, + } + ) + + +def test_goal_session_round_trip_dump_validate() -> None: + session = GoalSession.model_validate( + { + "session_id": "goal_001", + "kb_name": "calc", + "goal_config": { + "goal_level": "foundation", + "daily_minutes": 60, + }, + "artifacts": { + "root_dir": "data/user/goal/goal_001", + }, + } + ) + + dumped = session.model_dump(mode="json") + restored = GoalSession.model_validate(dumped) + + assert restored.session_id == "goal_001" + assert restored.goal_config.daily_minutes == 60 diff --git a/tests/goal/test_orchestrator.py b/tests/goal/test_orchestrator.py new file mode 100644 index 000000000..cc04c482f --- /dev/null +++ b/tests/goal/test_orchestrator.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from deeptutor.agents.goal.exercise_adapter import ExerciseAdapter +from deeptutor.agents.goal.extractor import KnowledgeExtractor +from deeptutor.agents.goal.graph_builder import GraphBuilder +from deeptutor.agents.goal.models import Feedback, GoalConfig +from deeptutor.agents.goal.orchestrator import GoalOrchestrator +from deeptutor.agents.goal.planner import Planner +from deeptutor.agents.goal.scheduler import Scheduler +from deeptutor.agents.goal.storage import GoalStorage +from deeptutor.agents.goal.weight_model import WeightModel + + +@pytest.mark.asyncio +async def test_orchestrator_runs_end_to_end_without_kb(tmp_path: Path) -> None: + storage = GoalStorage(base_dir=tmp_path / "goal") + extractor = KnowledgeExtractor(kb_base_dir=tmp_path / "knowledge_bases") + orchestrator = GoalOrchestrator( + storage=storage, + extractor=extractor, + graph_builder=GraphBuilder(), + weight_model=WeightModel(), + planner=Planner(), + scheduler=Scheduler(), + exercise_adapter=ExerciseAdapter(), + ) + + session = await orchestrator.create_session( + "placeholder_kb", + GoalConfig(goal_level="foundation", daily_minutes=90, remaining_days=3), + ) + plan = await orchestrator.run_plan(session.session_id) + + assert plan.plan_version == 1 + assert len(plan.tasks) >= 3 + assert len(plan.days) == 3 + + feedback_result = await orchestrator.submit_feedback( + session.session_id, + Feedback.model_validate( + { + "feedback_id": "fb_task_001", + "session_id": session.session_id, + "task_id": plan.tasks[0].task_id, + "completion": "partial", + "quiz": {"score": 2, "total": 5}, + "timestamp": "2026-04-09T20:00:00+08:00", + } + ), + ) + assert feedback_result["accepted"] is True + + replanned = await orchestrator.replan(session.session_id, "manual_feedback") + assert replanned.plan_version == 2 + assert replanned.diff is not None + assert replanned.diff.added_tasks + + practice = await orchestrator.generate_practice(session.session_id, replanned.tasks[0].task_id, count=2) + assert practice["practice_set_path"].endswith(".json") + + +@pytest.mark.asyncio +async def test_get_day_plan_detail_returns_blocks_for_existing_day(tmp_path: Path) -> None: + storage = GoalStorage(base_dir=tmp_path / "goal") + extractor = KnowledgeExtractor(kb_base_dir=tmp_path / "knowledge_bases") + orchestrator = GoalOrchestrator( + storage=storage, + extractor=extractor, + graph_builder=GraphBuilder(), + weight_model=WeightModel(), + planner=Planner(), + scheduler=Scheduler(), + exercise_adapter=ExerciseAdapter(), + ) + + session = await orchestrator.create_session( + "placeholder_kb", + GoalConfig(goal_level="foundation", daily_minutes=90, remaining_days=3), + ) + plan = await orchestrator.run_plan(session.session_id) + first_task = next(task for task in plan.tasks if task.day_index == 1) + + detail = orchestrator.get_day_plan_detail(session.session_id, 1) + assert detail.day_index == 1 + assert detail.time_blocks + assert first_task.task_id in detail.linked_task_ids + assert detail.objective_summary + + +@pytest.mark.asyncio +async def test_get_day_plan_detail_returns_fallback_for_empty_day(tmp_path: Path) -> None: + storage = GoalStorage(base_dir=tmp_path / "goal") + extractor = KnowledgeExtractor(kb_base_dir=tmp_path / "knowledge_bases") + orchestrator = GoalOrchestrator( + storage=storage, + extractor=extractor, + graph_builder=GraphBuilder(), + weight_model=WeightModel(), + planner=Planner(), + scheduler=Scheduler(), + exercise_adapter=ExerciseAdapter(), + ) + + session = await orchestrator.create_session( + "placeholder_kb", + GoalConfig(goal_level="foundation", daily_minutes=90, remaining_days=3), + ) + await orchestrator.run_plan(session.session_id) + + detail = orchestrator.get_day_plan_detail(session.session_id, 4) + assert detail.day_index == 4 + assert len(detail.time_blocks) == 1 + assert detail.time_blocks[0].title == "轻量复盘与预热" + assert detail.linked_task_ids == [] + + +@pytest.mark.asyncio +async def test_get_day_plan_detail_raises_for_invalid_day_index(tmp_path: Path) -> None: + storage = GoalStorage(base_dir=tmp_path / "goal") + extractor = KnowledgeExtractor(kb_base_dir=tmp_path / "knowledge_bases") + orchestrator = GoalOrchestrator( + storage=storage, + extractor=extractor, + graph_builder=GraphBuilder(), + weight_model=WeightModel(), + planner=Planner(), + scheduler=Scheduler(), + exercise_adapter=ExerciseAdapter(), + ) + session = await orchestrator.create_session( + "placeholder_kb", + GoalConfig(goal_level="foundation", daily_minutes=90, remaining_days=3), + ) + await orchestrator.run_plan(session.session_id) + + with pytest.raises(ValueError, match="day_index must be > 0"): + orchestrator.get_day_plan_detail(session.session_id, 0) + + +@pytest.mark.asyncio +async def test_submit_feedback_persists_task_status_to_plan(tmp_path: Path) -> None: + storage = GoalStorage(base_dir=tmp_path / "goal") + extractor = KnowledgeExtractor(kb_base_dir=tmp_path / "knowledge_bases") + orchestrator = GoalOrchestrator( + storage=storage, + extractor=extractor, + graph_builder=GraphBuilder(), + weight_model=WeightModel(), + planner=Planner(), + scheduler=Scheduler(), + exercise_adapter=ExerciseAdapter(), + ) + + session = await orchestrator.create_session( + "placeholder_kb", + GoalConfig(goal_level="foundation", daily_minutes=90, remaining_days=3), + ) + plan = await orchestrator.run_plan(session.session_id) + target_task = plan.tasks[0] + + await orchestrator.submit_feedback( + session.session_id, + Feedback.model_validate( + { + "feedback_id": "fb_task_status_001", + "session_id": session.session_id, + "task_id": target_task.task_id, + "completion": "done", + "timestamp": "2026-04-10T20:00:00+08:00", + } + ), + ) + + persisted_plan = storage.load_plan(session.session_id) + persisted_task = next(task for task in persisted_plan.tasks if task.task_id == target_task.task_id) + assert persisted_task.status.value == "done" diff --git a/tests/goal/test_planner.py b/tests/goal/test_planner.py new file mode 100644 index 000000000..8ba141760 --- /dev/null +++ b/tests/goal/test_planner.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from deeptutor.agents.goal.models import GoalConfig, KnowledgeEdge, KnowledgeNode +from deeptutor.agents.goal.planner import Planner + + +def test_planner_respects_prerequisite_order() -> None: + planner = Planner() + nodes = [ + KnowledgeNode.model_validate( + { + "node_id": "node_limit", + "title": "极限", + "estimated_minutes": 30, + "weight": 0.6, + } + ), + KnowledgeNode.model_validate( + { + "node_id": "node_derivative", + "title": "导数", + "estimated_minutes": 30, + "weight": 0.9, + "prerequisites": ["node_limit"], + } + ), + ] + edges = [ + KnowledgeEdge.model_validate( + { + "from": "node_limit", + "to": "node_derivative", + "edge_type": "prerequisite", + } + ) + ] + + tasks = planner.plan( + nodes, + edges, + GoalConfig(goal_level="foundation", daily_minutes=60, remaining_days=2, kb_name="calc"), + ) + + learn_tasks = [task for task in tasks if task.kind.value == "learn"] + assert learn_tasks[0].node_id == "node_limit" + assert learn_tasks[1].node_id == "node_derivative" + + +def test_planner_backfills_days_when_nodes_are_sparse() -> None: + planner = Planner() + nodes = [ + KnowledgeNode.model_validate( + { + "node_id": "node_limit", + "title": "极限", + "estimated_minutes": 30, + "weight": 0.8, + } + ), + KnowledgeNode.model_validate( + { + "node_id": "node_derivative", + "title": "导数", + "estimated_minutes": 35, + "weight": 0.7, + } + ), + ] + + tasks = planner.plan( + nodes, + [], + GoalConfig(goal_level="foundation", daily_minutes=90, remaining_days=5, kb_name="calc"), + ) + + covered_days = {task.day_index for task in tasks} + assert covered_days == {1, 2, 3, 4, 5} + assert any(task.kind.value == "review" for task in tasks) diff --git a/tests/goal/test_scheduler.py b/tests/goal/test_scheduler.py new file mode 100644 index 000000000..7214a2e18 --- /dev/null +++ b/tests/goal/test_scheduler.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from deeptutor.agents.goal.models import Feedback, Plan +from deeptutor.agents.goal.scheduler import Scheduler + + +def _build_plan() -> Plan: + return Plan.model_validate( + { + "plan_id": "plan_001", + "session_id": "goal_001", + "kb_name": "calc", + "plan_version": 1, + "days": [ + {"day_index": 1, "budget_minutes": 90, "task_ids": ["task_day1_001"]}, + {"day_index": 2, "budget_minutes": 90, "task_ids": []}, + ], + "tasks": [ + { + "task_id": "task_day1_001", + "day_index": 1, + "node_id": "node_limit", + "title": "学习极限", + "kind": "learn", + "objective": "掌握极限定义", + "estimate_minutes": 45, + "practice_spec": { + "difficulty": "easy", + "question_type": "short_answer", + "count": 3, + }, + } + ], + "artifacts": {"root_dir": "data/user/goal/goal_001"}, + } + ) + + +def test_scheduler_adds_review_and_moves_partial_task() -> None: + scheduler = Scheduler() + plan = _build_plan() + feedback = Feedback.model_validate( + { + "feedback_id": "fb_001", + "session_id": "goal_001", + "task_id": "task_day1_001", + "completion": "partial", + "quiz": {"score": 2, "total": 5}, + "timestamp": "2026-04-09T20:00:00+08:00", + } + ) + + new_plan, diff = scheduler.replan(plan, [feedback]) + + assert new_plan.plan_version == 2 + assert "task_day1_001_review" in diff["added_tasks"] + assert "task_day1_001_drill" in diff["added_tasks"] + assert "task_day1_001" in diff["moved_tasks"] + assert any(task.task_id == "task_day1_001_review" for task in new_plan.tasks) + assert any(task.task_id == "task_day1_001_drill" for task in new_plan.tasks) diff --git a/tests/goal/test_storage.py b/tests/goal/test_storage.py new file mode 100644 index 000000000..2d24d6b8e --- /dev/null +++ b/tests/goal/test_storage.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from deeptutor.agents.goal.models import GoalSession, Plan +from deeptutor.agents.goal.storage import GoalStorage + + +def _build_session() -> GoalSession: + return GoalSession.model_validate( + { + "session_id": "goal_001", + "kb_name": "calc", + "goal_config": { + "goal_level": "foundation", + "daily_minutes": 90, + }, + "artifacts": { + "root_dir": "data/user/goal/goal_001", + }, + } + ) + + +def _build_plan(version: int = 1) -> Plan: + return Plan.model_validate( + { + "plan_id": "plan_001", + "session_id": "goal_001", + "kb_name": "calc", + "plan_version": version, + "days": [ + { + "day_index": 1, + "budget_minutes": 90, + "task_ids": ["task_001"], + } + ], + "tasks": [ + { + "task_id": "task_001", + "day_index": 1, + "node_id": "node_limit", + "title": "Learn limit basics", + "kind": "learn", + "objective": "Understand the core definition", + "estimate_minutes": 45, + } + ], + "artifacts": { + "root_dir": "data/user/goal/goal_001", + }, + } + ) + + +def test_storage_saves_and_loads_session(tmp_path: Path) -> None: + storage = GoalStorage(base_dir=tmp_path) + session = _build_session() + + path = storage.save_session(session) + loaded = storage.load_session(session.session_id) + + assert Path(path).exists() + assert loaded.session_id == session.session_id + + +def test_storage_version_snapshots_existing_plan(tmp_path: Path) -> None: + storage = GoalStorage(base_dir=tmp_path) + storage.save_plan(_build_plan(version=1)) + storage.save_plan(_build_plan(version=2)) + + snapshot = tmp_path / "goal_001" / "plan.v1.json" + current = tmp_path / "goal_001" / "plan.json" + + assert snapshot.exists() + assert current.exists() + assert json.loads(snapshot.read_text(encoding="utf-8"))["plan_version"] == 1 + + +def test_storage_appends_events(tmp_path: Path) -> None: + storage = GoalStorage(base_dir=tmp_path) + storage.append_event("goal_001", {"type": "stage", "stage": "extract", "progress": 0.4}) + + event_file = tmp_path / "goal_001" / "events.jsonl" + lines = event_file.read_text(encoding="utf-8").strip().splitlines() + + assert len(lines) == 1 + assert json.loads(lines[0])["stage"] == "extract" diff --git a/tests/goal/test_weight_model.py b/tests/goal/test_weight_model.py new file mode 100644 index 000000000..153729ac4 --- /dev/null +++ b/tests/goal/test_weight_model.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from deeptutor.agents.goal.models import GoalConfig, KnowledgeNode +from deeptutor.agents.goal.weight_model import WeightModel + + +def test_weight_model_applies_formula_and_foundation_bias() -> None: + model = WeightModel() + nodes = [ + KnowledgeNode.model_validate( + { + "node_id": "node_limit", + "title": "极限", + "tags": ["基础"], + "signals": { + "doc_freq": 0.8, + "practice_freq": 0.4, + "heading_score": 0.6, + "weakness_score": 0.1, + }, + } + ), + KnowledgeNode.model_validate( + { + "node_id": "node_chain_rule", + "title": "链式法则", + "tags": ["综合"], + "signals": { + "doc_freq": 0.5, + "practice_freq": 0.3, + "heading_score": 0.2, + "weakness_score": 0.2, + }, + } + ), + ] + + scored = model.score( + nodes=nodes, + edges=[], + goal_config=GoalConfig(goal_level="foundation", daily_minutes=90, remaining_days=7), + ) + + # 0.35*0.8 + 0.25*0.4 + 0.20*0.6 + 0.20*0.1 + foundation_bias(0.15) + # + short-horizon practice boost(0.08*0.4) + high-yield strategy boost(0.1*0.8) + assert scored[0].node_id == "node_limit" + assert scored[0].weight == 0.782 + + +def test_weight_model_uses_feedback_weakness_override() -> None: + model = WeightModel() + node = KnowledgeNode.model_validate( + { + "node_id": "node_integral", + "title": "积分", + "signals": { + "doc_freq": 0.2, + "practice_freq": 0.2, + "heading_score": 0.2, + "weakness_score": 0.1, + }, + } + ) + + scored = model.score( + nodes=[node], + edges=[], + goal_config=GoalConfig(goal_level="competent", daily_minutes=90, remaining_days=7), + feedback_summary={"weakness_score": {"node_integral": 0.9}}, + ) + + # 0.35*0.2 + 0.25*0.2 + 0.20*0.2 + 0.20*0.9 + # + short-horizon practice boost(0.08*0.2) + high-yield strategy boost(0.1*0.2) + assert scored[0].weight == 0.376 diff --git a/tests/scripts/test_start_tour.py b/tests/scripts/test_start_tour.py deleted file mode 100644 index 26e0341b1..000000000 --- a/tests/scripts/test_start_tour.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -import importlib.util -import locale -from pathlib import Path -import sys -import types - - -def _load_start_tour_module(): - module_path = Path(__file__).resolve().parents[2] / "scripts" / "start_tour.py" - - cli_kit = types.ModuleType("_cli_kit") - for name in ( - "accent", - "banner", - "bold", - "confirm", - "countdown", - "dim", - "log_error", - "log_info", - "log_success", - "log_warn", - "select", - "step", - "text_input", - ): - setattr(cli_kit, name, lambda *args, **kwargs: None) - - deeptutor_pkg = types.ModuleType("deeptutor") - services_pkg = types.ModuleType("deeptutor.services") - config_module = types.ModuleType("deeptutor.services.config") - config_module.get_config_test_runner = lambda: None - config_module.get_env_store = lambda: None - config_module.get_model_catalog_service = lambda: None - - original_modules = { - "_cli_kit": sys.modules.get("_cli_kit"), - "deeptutor": sys.modules.get("deeptutor"), - "deeptutor.services": sys.modules.get("deeptutor.services"), - "deeptutor.services.config": sys.modules.get("deeptutor.services.config"), - } - - services_pkg.config = config_module - deeptutor_pkg.services = services_pkg - sys.modules["_cli_kit"] = cli_kit - sys.modules["deeptutor"] = deeptutor_pkg - sys.modules["deeptutor.services"] = services_pkg - sys.modules["deeptutor.services.config"] = config_module - - try: - spec = importlib.util.spec_from_file_location("start_tour_under_test", module_path) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - finally: - for module_name, original_module in original_modules.items(): - if original_module is None: - sys.modules.pop(module_name, None) - else: - sys.modules[module_name] = original_module - - -def test_stream_text_kwargs_use_best_effort_decoding() -> None: - start_tour = _load_start_tour_module() - - kwargs = start_tour._stream_text_kwargs() - - assert kwargs["text"] is True - assert kwargs["errors"] == "replace" - assert kwargs["encoding"] == locale.getpreferredencoding(False) diff --git a/tests/services/memory/test_memory_service.py b/tests/services/memory/test_memory_service.py index 9121dc517..4ef056846 100644 --- a/tests/services/memory/test_memory_service.py +++ b/tests/services/memory/test_memory_service.py @@ -4,9 +4,9 @@ from deeptutor.services.session.sqlite_store import SQLiteSessionStore -def _make_service(tmp_path): +def test_memory_service_snapshot_is_empty_without_file(tmp_path) -> None: store = SQLiteSessionStore(tmp_path / "chat_history.db") - return MemoryService( + service = MemoryService( path_service=type( "PathServiceStub", (), @@ -15,28 +15,32 @@ def _make_service(tmp_path): store=store, ) - -def test_memory_service_snapshot_is_empty_without_file(tmp_path) -> None: - service = _make_service(tmp_path) snapshot = service.read_snapshot() - assert snapshot.summary == "" - assert snapshot.profile == "" - assert snapshot.summary_updated_at is None - assert snapshot.profile_updated_at is None + assert snapshot.content == "" + assert snapshot.exists is False + assert snapshot.updated_at is None -async def _no_change_stream(**_kwargs): - yield "NO_CHANGE" +async def _no_change_llm(**_kwargs) -> str: + return "NO_CHANGE" -async def _rewrite_stream(**_kwargs): - yield "## Preferences\n- Prefer concise answers.\n\n## Context\n- Working on DeepTutor memory." +async def _rewrite_llm(**_kwargs) -> str: + return "## Preferences\n- Prefer concise answers.\n\n## Context\n- Working on DeepTutor memory." def test_memory_service_refresh_turn_writes_rewritten_document(monkeypatch, tmp_path) -> None: - service = _make_service(tmp_path) - monkeypatch.setattr("deeptutor.services.memory.service.llm_stream", _rewrite_stream) + store = SQLiteSessionStore(tmp_path / "chat_history.db") + service = MemoryService( + path_service=type( + "PathServiceStub", + (), + {"get_memory_dir": lambda self: tmp_path / "memory"}, + )(), + store=store, + ) + monkeypatch.setattr("deeptutor.services.memory.service.llm_complete", _rewrite_llm) import asyncio @@ -51,16 +55,25 @@ def test_memory_service_refresh_turn_writes_rewritten_document(monkeypatch, tmp_ ) assert result.changed is True + assert "## Preferences" in result.content assert "concise answers" in result.content - assert service._path("profile").exists() or service._path("summary").exists() + assert service.memory_path.exists() def test_memory_service_refresh_turn_skips_when_model_returns_no_change( monkeypatch, tmp_path, ) -> None: - service = _make_service(tmp_path) - monkeypatch.setattr("deeptutor.services.memory.service.llm_stream", _no_change_stream) + store = SQLiteSessionStore(tmp_path / "chat_history.db") + service = MemoryService( + path_service=type( + "PathServiceStub", + (), + {"get_memory_dir": lambda self: tmp_path / "memory"}, + )(), + store=store, + ) + monkeypatch.setattr("deeptutor.services.memory.service.llm_complete", _no_change_llm) import asyncio @@ -76,5 +89,4 @@ def test_memory_service_refresh_turn_skips_when_model_returns_no_change( assert result.changed is False assert result.content == "" - assert not service._path("profile").exists() - assert not service._path("summary").exists() + assert service.memory_path.exists() is False diff --git a/web/app/(utility)/knowledge/page.tsx b/web/app/(utility)/knowledge/page.tsx index 3657eba1a..b38e79833 100644 --- a/web/app/(utility)/knowledge/page.tsx +++ b/web/app/(utility)/knowledge/page.tsx @@ -298,17 +298,9 @@ export default function KnowledgePage() { for (const kb of kbs) { const status = kb.status ?? kb.statistics?.status; const progress = kb.progress ?? kb.statistics?.progress; - const progressStage = (progress as ProgressInfo | undefined)?.stage; - if ( - status && - status !== "ready" && - status !== "error" && - progressStage !== "completed" && - progressStage !== "error" - ) { + if (status && status !== "ready" && status !== "error") { setProgressMap((prev) => ({ ...prev, [kb.name]: progress || prev[kb.name] || {} })); - const taskId = (progress as ProgressInfo | undefined)?.task_id; - subscribeProgress(kb.name, taskId || undefined); + subscribeProgress(kb.name); } } } catch (error) { @@ -351,9 +343,7 @@ export default function KnowledgePage() { const stage = progress.stage; if (stage === "completed" || stage === "error") { closeProgressSocket(kbName); - if (expectedTaskId) { - void loadAll(); - } + void loadAll(); } } catch { // Ignore malformed progress events. diff --git a/web/app/(utility)/memory/page.tsx b/web/app/(utility)/memory/page.tsx index bfca3b5bb..68f67749c 100644 --- a/web/app/(utility)/memory/page.tsx +++ b/web/app/(utility)/memory/page.tsx @@ -23,17 +23,17 @@ interface MemoryData { const TABS: { key: MemoryFile; label: string; icon: typeof Brain; hint: string; placeholder: string }[] = [ { key: "summary", - label: "Summary", + label: "Memory Summary", icon: BookOpen, hint: "Running summary of the learning journey. Auto-updated after conversations.", - placeholder: "## Current Focus\n- ...\n\n## Accomplishments\n- ...\n\n## Open Questions\n- ...", + placeholder: "## Current Focus\n- ...\n\n## Completed Items\n- ...\n\n## Open Questions\n- ...", }, { key: "profile", - label: "Profile", + label: "Memory Profile", icon: User, hint: "User identity, preferences, and knowledge levels. Auto-updated after conversations.", - placeholder: "## Identity\n- ...\n\n## Learning Style\n- ...\n\n## Knowledge Level\n- ...\n\n## Preferences\n- ...", + placeholder: "## User Identity\n- ...\n\n## Learning Style\n- ...\n\n## Knowledge Level\n- ...\n\n## Preferences\n- ...", }, ]; @@ -44,10 +44,10 @@ const EMPTY: MemoryData = { profile_updated_at: null, }; -function formatUpdatedAt(value: string | null): string { - if (!value) return "Not updated yet"; +function formatUpdatedAt(value: string | null, t: (key: string) => string): string { + if (!value) return t("Not updated yet"); const date = new Date(value); - if (Number.isNaN(date.getTime())) return "Unknown"; + if (Number.isNaN(date.getTime())) return t("Unknown"); return date.toLocaleString(); } @@ -66,6 +66,7 @@ export default function MemoryPage() { const textareaRef = useRef(null); const tab = TABS.find((t) => t.key === activeTab)!; + const tabLabel = t(tab.label); const editorValue = editors[activeTab]; const hasChanges = editorValue !== data[activeTab]; const updatedAt = data[`${activeTab}_updated_at` as keyof MemoryData] as string | null; @@ -101,11 +102,11 @@ export default function MemoryPage() { const d: MemoryData = await res.json(); setData(d); setEditors((prev) => ({ ...prev, [activeTab]: d[activeTab] || "" })); - setToast(`${tab.label} saved`); + setToast(t("{{name}} saved", { name: tabLabel })); } finally { setSaving(false); } - }, [activeTab, editorValue, tab.label]); + }, [activeTab, editorValue, tabLabel, t]); const refreshMemory = useCallback(async () => { setRefreshing(true); @@ -118,14 +119,14 @@ export default function MemoryPage() { const d: MemoryData = await res.json(); setData(d); setEditors({ summary: d.summary || "", profile: d.profile || "" }); - setToast("Memory refreshed from session"); + setToast(t("Memory refreshed from session")); } finally { setRefreshing(false); } }, [activeSessionId, language]); const clearMemory = useCallback(async () => { - if (!window.confirm(`Clear ${tab.label}?`)) return; + if (!window.confirm(t("Clear {{name}}?", { name: tabLabel }))) return; setClearing(true); try { const res = await fetch(apiUrl("/api/v1/memory/clear"), { @@ -136,11 +137,11 @@ export default function MemoryPage() { const d: MemoryData = await res.json(); setData(d); setEditors((prev) => ({ ...prev, [activeTab]: d[activeTab] || "" })); - setToast(`${tab.label} cleared`); + setToast(t("{{name}} cleared", { name: tabLabel })); } finally { setClearing(false); } - }, [activeTab, tab.label]); + }, [activeTab, tabLabel, t]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -166,7 +167,7 @@ export default function MemoryPage() {

{toast}

) : (

- {hasChanges ? "Unsaved changes" : "All changes saved"} + {hasChanges ? t("Unsaved changes") : t("All changes saved")}

)}
@@ -177,7 +178,7 @@ export default function MemoryPage() { className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border)]/50 px-3 py-1.5 text-[12px] font-medium text-[var(--muted-foreground)] transition-colors hover:border-[var(--border)] hover:text-[var(--foreground)] disabled:opacity-40" > {saving ? : } - Save + {t("Save")}
{/* Tab selector */}
- {TABS.map((t) => { - const Icon = t.icon; - const active = activeTab === t.key; + {TABS.map((item) => { + const Icon = item.icon; + const active = activeTab === item.key; return ( ); })} @@ -222,7 +223,7 @@ export default function MemoryPage() { {/* Meta & View toggle */}
-

{tab.hint}

+

{t(tab.hint)}

{(["edit", "preview"] as const).map((v) => ( @@ -235,12 +236,12 @@ export default function MemoryPage() { : "text-[var(--muted-foreground)] hover:text-[var(--foreground)]" }`} > - {v === "edit" ? "Edit" : "Preview"} + {v === "edit" ? t("Edit") : t("Preview")} ))}
- Updated: {formatUpdatedAt(updatedAt)} + {t("Updated")}: {formatUpdatedAt(updatedAt, t)}
@@ -274,7 +275,9 @@ export default function MemoryPage() {
-

No {tab.label.toLowerCase()} yet

+

+ {t("No {{name}} yet", { name: t(tab.label).toLowerCase() })} +

{t("Refresh from a session or write directly in the editor.")}

diff --git a/web/app/(utility)/settings/page.tsx b/web/app/(utility)/settings/page.tsx index dfbd0efcf..53e74ace4 100644 --- a/web/app/(utility)/settings/page.tsx +++ b/web/app/(utility)/settings/page.tsx @@ -21,8 +21,6 @@ import { X, } from "lucide-react"; -import { useTranslation } from "react-i18next"; - import { writeStoredLanguage } from "@/context/AppShellContext"; import { apiUrl } from "@/lib/api"; import { setTheme as applyThemePreference } from "@/lib/theme"; @@ -70,12 +68,9 @@ type UiSettings = { language: "en" | "zh"; }; -type ProviderOption = { value: string; label: string; base_url?: string }; - type SettingsPayload = { ui: UiSettings; catalog: Catalog; - providers?: Record; }; type SystemStatus = { @@ -147,9 +142,6 @@ function defaultCatalog(): Catalog { const inputClass = "w-full rounded-lg border border-[var(--border)] bg-transparent px-3 py-2 text-[14px] text-[var(--foreground)] outline-none transition-colors focus:border-[var(--ring)] placeholder:text-[var(--muted-foreground)]/40"; -const selectClass = - "w-full appearance-none rounded-lg border border-[var(--border)] bg-transparent px-3 py-2 text-[14px] text-[var(--foreground)] outline-none transition-colors focus:border-[var(--ring)] cursor-pointer"; - function stringifyExtraHeaders(value: CatalogProfile["extra_headers"]): string { if (!value) return ""; if (typeof value === "string") return value; @@ -194,7 +186,6 @@ function SpotlightOverlay({ onNext: () => void; onSkip: () => void; }) { - const { t } = useTranslation(); const [rect, setRect] = useState(null); const guideStep = TOUR_GUIDE_STEPS[stepIndex]; @@ -238,23 +229,23 @@ function SpotlightOverlay({ style={{ top: tooltipTop, left: tooltipLeft }} >
- {t(guideStep.title)} + {guideStep.title}

- {t(guideStep.desc)} + {guideStep.desc}

@@ -278,7 +269,6 @@ function TestResultsModal({ onConfirm: () => void; onCancel: () => void; }) { - const { t } = useTranslation(); const hasCriticalFailure = results.llm === "fail" || results.embedding === "fail"; const allDone = !testing && results.llm !== "pending" && results.embedding !== "pending"; @@ -290,10 +280,10 @@ function TestResultsModal({ }; const label = (r: TourTestResult) => { - if (r === "pass") return t("Passed"); - if (r === "fail") return t("Failed"); - if (r === "skip") return t("Skipped"); - return t("Testing..."); + if (r === "pass") return "Passed"; + if (r === "fail") return "Failed"; + if (r === "skip") return "Skipped"; + return "Testing..."; }; return ( @@ -301,7 +291,7 @@ function TestResultsModal({

- {testing ? t("Running tests...") : t("Test Results")} + {testing ? "Running tests..." : "Test Results"}

{!testing && (
)} @@ -349,7 +339,7 @@ function TestResultsModal({ {testing && (
- {t("Please wait...")} + Please wait...
)}
@@ -362,13 +352,12 @@ function TestResultsModal({ // ═══════════════════════════════════════════════════════════════════════════ function SettingsPageContent() { - const { t } = useTranslation(); const searchParams = useSearchParams(); const isTourMode = searchParams.get("tour") === "true"; const [status, setStatus] = useState(null); const [theme, setTheme] = useState<"light" | "dark">("light"); - const [language, setLanguage] = useState<"en" | "zh">("en"); + const [language, setLanguage] = useState<"en" | "zh">("zh"); const [catalog, setCatalog] = useState(defaultCatalog()); const [draft, setDraft] = useState(defaultCatalog()); const [activeService, setActiveService] = useState("llm"); @@ -378,7 +367,6 @@ function SettingsPageContent() { const [applying, setApplying] = useState(false); const [toast, setToast] = useState(""); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); - const [providers, setProviders] = useState>({ llm: [], embedding: [], search: [] }); const eventSourceRef = useRef(null); // Tour-specific state @@ -399,7 +387,6 @@ function SettingsPageContent() { setDraft(cloneCatalog(settingsPayload.catalog)); setTheme(settingsPayload.ui.theme); setLanguage(settingsPayload.ui.language); - if (settingsPayload.providers) setProviders(settingsPayload.providers); const statusResponse = await fetch(apiUrl("/api/v1/system/status")); const statusPayload = (await statusResponse.json()) as SystemStatus; @@ -590,7 +577,7 @@ function SettingsPageContent() { const payload = await response.json(); setCatalog(payload.catalog); setDraft(cloneCatalog(payload.catalog)); - setToast(t("Draft saved")); + setToast("Draft saved"); } finally { setSaving(false); } @@ -607,7 +594,7 @@ function SettingsPageContent() { const payload = await response.json(); setCatalog(payload.catalog); setDraft(cloneCatalog(payload.catalog)); - setToast(t("Applied to .env")); + setToast("Applied to .env"); const statusResponse = await fetch(apiUrl("/api/v1/system/status")); setStatus((await statusResponse.json()) as SystemStatus); } finally { @@ -653,7 +640,7 @@ function SettingsPageContent() { eventSourceRef.current = null; setTestRunning(null); setLogs((current) => `${current}[failed] Diagnostics stream disconnected.\n`); - setToast(t("Diagnostics stream disconnected")); + setToast("Diagnostics stream disconnected"); }; } catch (error) { const message = error instanceof Error ? error.message : "Could not start diagnostics."; @@ -749,10 +736,10 @@ function SettingsPageContent() { setTourCompleted(true); setTourRedirectAt(payload.redirect_at ?? Math.floor(Date.now() / 1000) + 5); } else { - setToast(t("Failed to complete tour")); + setToast("Failed to complete tour"); } } catch { - setToast(t("Failed to complete tour")); + setToast("Failed to complete tour"); } }; @@ -765,7 +752,7 @@ function SettingsPageContent() { const reopenTour = async () => { const response = await fetch(apiUrl("/api/v1/settings/tour/reopen"), { method: "POST" }); const payload = (await response.json()) as { command?: string; message?: string }; - setToast(payload.command ? t("Run {{command}} in your terminal.", { command: payload.command }) : payload.message || t("Run python scripts/start_tour.py in your terminal.")); + setToast(payload.command ? `Run ${payload.command} in your terminal.` : payload.message || "Run python scripts/start_tour.py in your terminal."); }; // ═══════════════════════════════════════════════════════════════════════ @@ -782,10 +769,10 @@ function SettingsPageContent() {
- {t("Setup Tour")} + Setup Tour

- {t("Configure your endpoints below, run tests, then launch DeepTutor.")} + Configure your endpoints below, run tests, then launch DeepTutor.

)} @@ -805,12 +792,12 @@ function SettingsPageContent() {
- {t("Configuration saved")} + Configuration saved

{redirectCountdown > 0 - ? t("Redirecting to DeepTutor in {{count}}s...", { count: redirectCountdown }) - : t("Redirecting...")} + ? `Redirecting to DeepTutor in ${redirectCountdown}s...` + : "Redirecting..."}

)} @@ -819,7 +806,7 @@ function SettingsPageContent() {

- {t("Settings")} + Settings

{toast ? (

@@ -827,7 +814,7 @@ function SettingsPageContent() {

) : (

- {hasUnsavedChanges ? t("Draft has unsaved changes") : t("All changes saved")} + {hasUnsavedChanges ? "Draft has unsaved changes" : "All changes saved"}

)}
@@ -838,12 +825,12 @@ function SettingsPageContent() { className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border)]/50 px-3 py-1.5 text-[12px] font-medium text-[var(--muted-foreground)] transition-colors hover:border-[var(--border)] hover:text-[var(--foreground)] disabled:opacity-40" > {saving ? : } - {t("Save Draft")} + Save Draft
@@ -859,7 +846,7 @@ function SettingsPageContent() { {/* ── Preferences & Runtime ── */}
- {t("Theme")} + Theme
{(["light", "dark"] as const).map((v) => ( ))}
- {t("Language")} + Language
{(["en", "zh"] as const).map((v) => ( ))}
@@ -899,20 +886,20 @@ function SettingsPageContent() {
- {t("Backend")} + Backend - {t("LLM")} + LLM {status?.llm.model && · {status.llm.model}} - {t("Emb")} + Emb - {t("Search")} + Search
@@ -946,7 +933,7 @@ function SettingsPageContent() { className="inline-flex items-center gap-1 rounded-lg border border-[var(--border)]/50 px-2.5 py-1 text-[12px] text-[var(--muted-foreground)] transition-colors hover:border-[var(--border)] hover:text-[var(--foreground)]" > - {t("Profile")} + Profile {activeService !== "search" && ( )}
@@ -984,7 +971,7 @@ function SettingsPageContent() { >
{profile.name}
- {profile.base_url || t("No endpoint")} + {profile.base_url || "No endpoint"}
))} @@ -994,7 +981,7 @@ function SettingsPageContent() { className="flex w-full items-center gap-1.5 rounded-lg px-3 py-2 text-[11px] text-[var(--muted-foreground)]/40 transition-colors hover:text-red-500 disabled:opacity-30" > - {t("Delete profile")} + Delete profile
@@ -1002,11 +989,11 @@ function SettingsPageContent() {
- {t("Profile")} + Profile
-
{t("Name")}
+
Name
- {t("Provider")} -
-
- - + {activeService === "search" ? "Provider" : "Provider Hint / Binding"}
+ + updateProfileField( + activeService === "search" ? "provider" : "binding", + e.target.value, + ) + } + placeholder={activeService === "search" ? "brave" : "openai"} + /> {showSearchProviderWarning && (

{isSupportedSearchProvider ? isPerplexityMissingKey - ? t("Perplexity requires API key. It will fail hard without credentials.") - : t("Supported provider.") + ? "Perplexity requires API key. It will fail hard without credentials." + : "Supported provider." : isDeprecatedSearchProvider - ? t("Deprecated provider. Switch to brave/tavily/jina/searxng/duckduckgo/perplexity.") - : t("Unsupported provider. Use brave/tavily/jina/searxng/duckduckgo/perplexity.")} + ? "Deprecated provider. Switch to brave/tavily/jina/searxng/duckduckgo/perplexity." + : "Unsupported provider. Use brave/tavily/jina/searxng/duckduckgo/perplexity."}

)}
-
{t("Base URL")}
+
Base URL
-
{t("API Key")}
+
API Key
-
{t("API Version")}
+
API Version
updateProfileField("api_version", e.target.value)} - placeholder={t("Optional")} + placeholder="Optional" />
{activeService === "search" ? (
-
{t("Proxy")}
+
Proxy
- {t("Extra Headers (JSON)")} + Extra Headers (JSON)