diff --git a/Dockerfile b/Dockerfile index 6526bbf7c..6f6549566 100644 --- a/Dockerfile +++ b/Dockerfile @@ -157,6 +157,7 @@ COPY --from=frontend-builder /app/web/public/ ./web/public/ # Copy application source code COPY deeptutor/ ./deeptutor/ +COPY deeptutor_compat/ ./deeptutor_compat/ COPY deeptutor_cli/ ./deeptutor_cli/ COPY scripts/ ./scripts/ COPY pyproject.toml ./ @@ -180,7 +181,7 @@ RUN mkdir -p \ data/user/logs \ data/knowledge_bases -# Create supervisord configuration for running both services +# Create supervisord configuration for running backend + frontend + compat gateway # Log output goes to stdout/stderr so docker logs can capture them RUN mkdir -p /etc/supervisor/conf.d @@ -213,6 +214,18 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/fd/2 stderr_logfile_maxbytes=0 environment=NODE_ENV="production" + +[program:compat-gateway] +command=/bin/bash /app/start-compat-gateway.sh +directory=/app +autostart=true +autorestart=true +startsecs=2 +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 +environment=PYTHONPATH="/app",PYTHONUNBUFFERED="1" EOF RUN sed -i 's/\r$//' /etc/supervisor/conf.d/deeptutor.conf @@ -279,6 +292,20 @@ EOF RUN sed -i 's/\r$//' /app/start-frontend.sh && chmod +x /app/start-frontend.sh +# Create compat gateway startup script (single-port entrypoint for dynamic_router) +RUN cat > /app/start-compat-gateway.sh <<'EOF' +#!/bin/bash +set -e + +GATEWAY_PORT=${GATEWAY_PORT:-3000} + +echo "[Compat] 🚪 Starting compat gateway on port ${GATEWAY_PORT}..." + +exec python -m uvicorn deeptutor_compat.gateway:app --host 0.0.0.0 --port ${GATEWAY_PORT} +EOF + +RUN sed -i 's/\r$//' /app/start-compat-gateway.sh && chmod +x /app/start-compat-gateway.sh + # Create entrypoint script RUN cat > /app/entrypoint.sh <<'EOF' #!/bin/bash @@ -291,9 +318,13 @@ echo "============================================" # Set default ports if not provided export BACKEND_PORT=${BACKEND_PORT:-8001} export FRONTEND_PORT=${FRONTEND_PORT:-3782} +export GATEWAY_PORT=${GATEWAY_PORT:-3000} +export DEEPTUTOR_WORKSPACE_ROOT=${DEEPTUTOR_WORKSPACE_ROOT:-/workspace/deeptutor} echo "📌 Backend Port: ${BACKEND_PORT}" echo "📌 Frontend Port: ${FRONTEND_PORT}" +echo "📌 Gateway Port: ${GATEWAY_PORT}" +echo "📌 DeepTutor Data Root: ${DEEPTUTOR_WORKSPACE_ROOT}" # Check for required environment variables if [ -z "$LLM_API_KEY" ]; then @@ -329,11 +360,11 @@ EOF RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh # Expose ports -EXPOSE 8001 3782 +EXPOSE 3000 8001 3782 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD curl -f http://localhost:${BACKEND_PORT:-8001}/ || exit 1 + CMD curl -f http://localhost:${GATEWAY_PORT:-3000}/health || exit 1 # Set entrypoint ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/deeptutor/agents/chat/agentic_pipeline.py b/deeptutor/agents/chat/agentic_pipeline.py index 2a5d3f4a0..a712694ba 100644 --- a/deeptutor/agents/chat/agentic_pipeline.py +++ b/deeptutor/agents/chat/agentic_pipeline.py @@ -35,6 +35,7 @@ ) from deeptutor.services.prompt import get_prompt_manager from deeptutor.services.prompt.language import append_language_directive +from deeptutor.services.prompt.oxca_brand import apply_student_guardrail from deeptutor.tools.builtin import BUILTIN_TOOL_NAMES from deeptutor.utils.json_parser import parse_json_response @@ -1416,7 +1417,10 @@ def _responding_system_prompt(self, enabled_tools: list[str]) -> str: tool_list=tool_list or self._fallback_empty_tool_list(), rag_hint=rag_hint, ) - return append_language_directive(system_prompt, self.language) + return apply_student_guardrail( + append_language_directive(system_prompt, self.language), + self.language, + ) def _acting_user_prompt(self, context: UnifiedContext, thinking_text: str) -> str: return self._t( diff --git a/deeptutor/agents/chat/chat_agent.py b/deeptutor/agents/chat/chat_agent.py index f532c9443..20d15aec4 100644 --- a/deeptutor/agents/chat/chat_agent.py +++ b/deeptutor/agents/chat/chat_agent.py @@ -16,6 +16,7 @@ from deeptutor.agents.base_agent import BaseAgent from deeptutor.runtime.registry.tool_registry import get_tool_registry from deeptutor.services.prompt.language import append_language_directive +from deeptutor.services.prompt.oxca_brand import apply_student_guardrail class ChatAgent(BaseAgent): @@ -247,8 +248,11 @@ def build_messages( messages = [] system_parts = [ - append_language_directive( - self.get_prompt("system", "You are a helpful AI assistant."), + apply_student_guardrail( + append_language_directive( + self.get_prompt("system", "You are OxCa's AI learning assistant."), + self.language, + ), self.language, ) ] diff --git a/deeptutor/agents/chat/prompts/en/agentic_chat.yaml b/deeptutor/agents/chat/prompts/en/agentic_chat.yaml index 15e82c873..3f976fc66 100644 --- a/deeptutor/agents/chat/prompts/en/agentic_chat.yaml +++ b/deeptutor/agents/chat/prompts/en/agentic_chat.yaml @@ -99,18 +99,19 @@ observing: # --------------------------------------------------------------------------- responding: system: |- - You are DeepTutor's final response stage. Use the observation and tool evidence to provide a clear, direct, well-structured answer to the user. + You are OxCa's AI tutor delivering the final answer to the student. Use the observation and tool evidence to provide a clear, direct, well-structured answer. Requirements: 1. Output only the final user-facing answer. 2. Do not reveal the internal chain, reasoning, or tool orchestration. - 3. Naturally integrate evidence or limits surfaced by the tools. + 3. Never mention DeepTutor, HKU, or any underlying platform or engine. + 4. Naturally integrate evidence or limits surfaced by the tools. {rag_hint} Tool context for this turn: {tool_list} # Injected at {rag_hint} when RAG is enabled rag_hint: |- - 4. If RAG was used, follow the observation's judgment on how to weigh retrieved content vs. general knowledge. When the retrieved content is highly relevant, ground your answer in it; when only partially relevant, supplement with your own knowledge. + 5. If RAG was used, follow the observation's judgment on how to weigh retrieved content vs. general knowledge. When the retrieved content is highly relevant, ground your answer in it; when only partially relevant, supplement with your own knowledge. user: |- User request: {user_message} diff --git a/deeptutor/agents/chat/prompts/en/chat_agent.yaml b/deeptutor/agents/chat/prompts/en/chat_agent.yaml index 298ff6c61..3a4c2503f 100644 --- a/deeptutor/agents/chat/prompts/en/chat_agent.yaml +++ b/deeptutor/agents/chat/prompts/en/chat_agent.yaml @@ -1,7 +1,7 @@ # Chat Agent Prompts (English) system: | - You are DeepTutor, an intelligent AI learning assistant developed by the Data Intelligence Lab at HKU. + You are OxCa's AI learning assistant (牛剑通途), an intelligent tutor that helps students learn STEM subjects and prepare for exams. Your capabilities: - Help students understand complex concepts across STEM subjects diff --git a/deeptutor/agents/chat/prompts/zh/agentic_chat.yaml b/deeptutor/agents/chat/prompts/zh/agentic_chat.yaml index 4c12bbea6..55d7eb9cc 100644 --- a/deeptutor/agents/chat/prompts/zh/agentic_chat.yaml +++ b/deeptutor/agents/chat/prompts/zh/agentic_chat.yaml @@ -99,18 +99,19 @@ observing: # --------------------------------------------------------------------------- responding: system: |- - 你是 DeepTutor 的最终回答阶段。请根据 observation 和工具结果,给用户一个清晰、直接、结构良好的正式答复。 + 你是 OxCa 的 AI 导师,正在给学生输出最终答复。请根据 observation 和工具结果,给出清晰、直接、结构良好的正式答复。 要求: 1. 只输出面向用户的正式回答。 2. 不要暴露内部链路、思考过程或工具编排。 - 3. 若工具结果提供了证据或限制,请自然融入答案。 + 3. 不要提及 DeepTutor、港大或任何底层平台/引擎。 + 4. 若工具结果提供了证据或限制,请自然融入答案。 {rag_hint} 本轮工具背景: {tool_list} # 当 RAG 工具被启用时,插入到 system 的 {rag_hint} 占位处 rag_hint: |- - 4. 若使用了知识库检索,请根据 observation 的判断,合理权衡检索内容与通识。当检索内容与问题高度相关时,以其为核心依据;部分相关时,可结合自身知识补充。 + 5. 若使用了知识库检索,请根据 observation 的判断,合理权衡检索内容与通识。当检索内容与问题高度相关时,以其为核心依据;部分相关时,可结合自身知识补充。 user: |- 用户问题: {user_message} diff --git a/deeptutor/agents/chat/prompts/zh/chat_agent.yaml b/deeptutor/agents/chat/prompts/zh/chat_agent.yaml index 762719aa7..c62788aa0 100644 --- a/deeptutor/agents/chat/prompts/zh/chat_agent.yaml +++ b/deeptutor/agents/chat/prompts/zh/chat_agent.yaml @@ -1,7 +1,7 @@ # Chat Agent Prompts (中文) system: | - 你是 DeepTutor,一个由香港大学数据智能实验室开发的智能 AI 学习助手。 + 你是 OxCa(牛剑通途)的 AI 学习助手,帮助学生理解 STEM 学科并备考。 你的能力: - 帮助学生理解 STEM 领域的复杂概念 diff --git a/deeptutor/agents/question/agents/idea_agent.py b/deeptutor/agents/question/agents/idea_agent.py index 3535de639..e0ebee5e1 100644 --- a/deeptutor/agents/question/agents/idea_agent.py +++ b/deeptutor/agents/question/agents/idea_agent.py @@ -49,13 +49,18 @@ async def process( existing_concentrations: list[str] | None = None, batch_number: int | None = None, attachments: list[Any] | None = None, + history_context: str = "", ) -> dict[str, Any]: """ Build grounded question templates in a single pass. """ batch_size = max(1, min(int(num_ideas or 1), BATCH_SIZE)) trace_id = f"batch-{batch_number}" if batch_number is not None else "ideation" - if self.enable_rag and self.kb_name: + attached_history = str(history_context or "").strip() + if attached_history: + knowledge_context = attached_history + retrievals: list[dict[str, Any]] = [] + elif self.enable_rag and self.kb_name: retrievals = await self._retrieve_context( user_topic, trace_id=trace_id, diff --git a/deeptutor/agents/question/coordinator.py b/deeptutor/agents/question/coordinator.py index 8fb0eb90e..b4676a6d1 100644 --- a/deeptutor/agents/question/coordinator.py +++ b/deeptutor/agents/question/coordinator.py @@ -158,6 +158,7 @@ async def generate_from_topic( existing_concentrations=existing_concentrations, batch_number=batch_number, attachments=attachments, + history_context=history_context, ) batch_templates = idea_result.get("templates", []) if not isinstance(batch_templates, list): diff --git a/deeptutor/agents/question/prompts/en/answer_now.yaml b/deeptutor/agents/question/prompts/en/answer_now.yaml index cd17ab3f8..f99c36561 100644 --- a/deeptutor/agents/question/prompts/en/answer_now.yaml +++ b/deeptutor/agents/question/prompts/en/answer_now.yaml @@ -1,5 +1,5 @@ system: | - You are DeepTutor's question generator. The user is waiting, so produce + You are OxCa's quiz generator. The user is waiting, so produce a complete question set in one shot using the context already gathered. Output strictly the JSON schema {"questions": [{"question_id": "q_1", "question": "...", "question_type": "choice|written|coding", "options": diff --git a/deeptutor/agents/question/prompts/zh/answer_now.yaml b/deeptutor/agents/question/prompts/zh/answer_now.yaml index 9cd0bdbbe..d24af19a7 100644 --- a/deeptutor/agents/question/prompts/zh/answer_now.yaml +++ b/deeptutor/agents/question/prompts/zh/answer_now.yaml @@ -1,5 +1,5 @@ system: | - 你是 DeepTutor 的题目生成器。用户已经在等待,请基于现有信息直接输出一组题目。 + 你是 OxCa 的题目生成器。用户已经在等待,请基于现有信息直接输出一组题目。 严格输出 JSON:{"questions": [{"question_id": "q_1", "question": "...", "question_type": "choice|written|coding", "options": {"A": "..."}, "correct_answer": "...", diff --git a/deeptutor/agents/research/prompts/en/answer_now.yaml b/deeptutor/agents/research/prompts/en/answer_now.yaml index be475d6f1..2d4c2df10 100644 --- a/deeptutor/agents/research/prompts/en/answer_now.yaml +++ b/deeptutor/agents/research/prompts/en/answer_now.yaml @@ -1,5 +1,5 @@ system: | - You are DeepTutor's research-report writer. The user is waiting, so + You are OxCa's research-report writer. The user is waiting, so produce a structured research report right now from whatever evidence has streamed so far (rephrase, decompose, search hits, notes/outline, ...). Do not retrieve more evidence and do not call tools. If coverage diff --git a/deeptutor/agents/research/prompts/zh/answer_now.yaml b/deeptutor/agents/research/prompts/zh/answer_now.yaml index 85bb91669..07ed608b7 100644 --- a/deeptutor/agents/research/prompts/zh/answer_now.yaml +++ b/deeptutor/agents/research/prompts/zh/answer_now.yaml @@ -1,5 +1,5 @@ system: | - 你是 DeepTutor 的研究报告写作组件。用户已经在等待, + 你是 OxCa 的研究报告写作组件。用户已经在等待, 请根据当前已经收集到的研究 trace(包括 rephrase、decompose、检索结果、 笔记/纲要等)直接输出一篇结构清晰的研究报告。 不要再继续检索或调用工具。如果证据稀薄,请在报告中标注信息覆盖度。 diff --git a/deeptutor/agents/solve/prompts/en/answer_now.yaml b/deeptutor/agents/solve/prompts/en/answer_now.yaml index b2e25269a..8d6abe97a 100644 --- a/deeptutor/agents/solve/prompts/en/answer_now.yaml +++ b/deeptutor/agents/solve/prompts/en/answer_now.yaml @@ -1,5 +1,5 @@ system: | - You are the writer component of DeepTutor. The user is already waiting, + You are OxCa's answer writer. The user is already waiting, so produce the final user-facing answer right now using only the partial reasoning trace that has streamed so far. Do not plan further or call tools, and do not mention internal stages. If something is still diff --git a/deeptutor/agents/solve/prompts/zh/answer_now.yaml b/deeptutor/agents/solve/prompts/zh/answer_now.yaml index 385244595..682dcfbdb 100644 --- a/deeptutor/agents/solve/prompts/zh/answer_now.yaml +++ b/deeptutor/agents/solve/prompts/zh/answer_now.yaml @@ -1,5 +1,5 @@ system: | - 你是 DeepTutor 的写作组件。用户已经在等待, + 你是 OxCa 的写作组件。用户已经在等待, 你必须基于当前已经收集到的推理与工具调用轨迹直接输出最终答复。 不要再做新的规划或调用工具,不要提到内部阶段。 如果信息仍有缺口,请诚实说明不确定之处,但仍尽可能给出当前最有用的回答。 diff --git a/deeptutor/agents/visualize/prompts/en/answer_now.yaml b/deeptutor/agents/visualize/prompts/en/answer_now.yaml index e762f74ce..0f07efe1d 100644 --- a/deeptutor/agents/visualize/prompts/en/answer_now.yaml +++ b/deeptutor/agents/visualize/prompts/en/answer_now.yaml @@ -1,5 +1,5 @@ system: | - You are DeepTutor's visualization code generator. The user is waiting, + You are OxCa's visualization code generator. The user is waiting, so emit the final renderable code in one shot. Output strictly the JSON {"render_type": "svg|chartjs|mermaid", "code": "..."}, where ``code`` is the renderable source (SVG markup, Chart.js JS, or Mermaid DSL). diff --git a/deeptutor/agents/visualize/prompts/zh/answer_now.yaml b/deeptutor/agents/visualize/prompts/zh/answer_now.yaml index 05c9f8c6e..87535bcd2 100644 --- a/deeptutor/agents/visualize/prompts/zh/answer_now.yaml +++ b/deeptutor/agents/visualize/prompts/zh/answer_now.yaml @@ -1,5 +1,5 @@ system: | - 你是 DeepTutor 的可视化代码生成器。用户已经在等待, + 你是 OxCa 的可视化代码生成器。用户已经在等待, 请直接输出最终可渲染的代码。 严格输出 JSON:{"render_type": "svg|chartjs|mermaid", "code": "..."}。 diff --git a/deeptutor/api/main.py b/deeptutor/api/main.py index ab87722f9..e61a8436a 100644 --- a/deeptutor/api/main.py +++ b/deeptutor/api/main.py @@ -202,7 +202,7 @@ async def lifespan(app: FastAPI): app = FastAPI( - title="DeepTutor API", + title="OxCa Learning API", version="1.0.0", lifespan=lifespan, # Disable automatic trailing slash redirects to prevent protocol downgrade issues @@ -364,7 +364,7 @@ async def selective_access_log(request, call_next): @app.get("/") async def root(): - return {"message": "Welcome to DeepTutor API"} + return {"message": "OxCa learning API"} if __name__ == "__main__": diff --git a/deeptutor/api/routers/question_notebook.py b/deeptutor/api/routers/question_notebook.py index 151a33545..1c960b9a6 100644 --- a/deeptutor/api/routers/question_notebook.py +++ b/deeptutor/api/routers/question_notebook.py @@ -70,6 +70,8 @@ class CategoryAddRequest(BaseModel): class UpsertEntryRequest(BaseModel): session_id: str + """When the session does not exist yet (e.g. OxCa Levels practice), create it.""" + session_title: str | None = None question_id: str question: str question_type: str = "" @@ -81,14 +83,32 @@ class UpsertEntryRequest(BaseModel): is_correct: bool = False +class EnsureSessionRequest(BaseModel): + session_id: str + title: str = Field(default="Practice notebook", min_length=1, max_length=100) + + # ── Entry endpoints ────────────────────────────────────────────── +@router.post("/sessions/ensure") +async def ensure_notebook_session(payload: EnsureSessionRequest): + """Create an empty chat session used to group notebook entries (Levels practice).""" + store = get_sqlite_session_store() + if await store.get_session(payload.session_id) is None: + await store.create_session(title=payload.title[:100], session_id=payload.session_id) + return {"session_id": payload.session_id} + + @router.post("/entries/upsert") async def upsert_single_entry(payload: UpsertEntryRequest): store = get_sqlite_session_store() + if await store.get_session(payload.session_id) is None: + title = (payload.session_title or "Practice notebook").strip() or "Practice notebook" + await store.create_session(title=title[:100], session_id=payload.session_id) + entry_payload = payload.model_dump(exclude={"session_title"}) try: - await store.upsert_notebook_entries(payload.session_id, [payload.model_dump()]) + await store.upsert_notebook_entries(payload.session_id, [entry_payload]) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) entry = await store.find_notebook_entry(payload.session_id, payload.question_id) diff --git a/deeptutor/capabilities/_answer_now.py b/deeptutor/capabilities/_answer_now.py index ad54ab707..fde8ffda0 100644 --- a/deeptutor/capabilities/_answer_now.py +++ b/deeptutor/capabilities/_answer_now.py @@ -41,6 +41,7 @@ stream as llm_stream, ) from deeptutor.services.prompt.manager import get_prompt_manager +from deeptutor.services.prompt.oxca_brand import apply_student_guardrail # Per-event content cap. The trace can grow unbounded (especially for # deep_research / deep_solve with many tool calls) so we truncate each @@ -254,7 +255,15 @@ def load_answer_now_prompts(module: str, language: str) -> dict[str, Any]: return get_prompt_manager().load_prompts(module, "answer_now", language) +def answer_now_system_prompt(module: str, language: str) -> str: + """Load and white-label the answer-now system prompt for student output.""" + prompts = load_answer_now_prompts(module, language) + raw = str(prompts.get("system", "")).strip() + return apply_student_guardrail(raw, language) + + __all__ = [ + "answer_now_system_prompt", "build_answer_now_trace_metadata", "extract_answer_now_context", "format_trace_summary", diff --git a/deeptutor/capabilities/deep_question.py b/deeptutor/capabilities/deep_question.py index 739708ba2..3809dd865 100644 --- a/deeptutor/capabilities/deep_question.py +++ b/deeptutor/capabilities/deep_question.py @@ -88,7 +88,13 @@ async def run(self, context: UnifiedContext, stream: StreamBus) -> None: difficulty = str(overrides.get("difficulty", "") or "") question_type = str(overrides.get("question_type", "") or "") preference = str(overrides.get("preference", "") or "") - history_context = str(context.metadata.get("conversation_context_text", "") or "").strip() + # Tutor-inline quiz runs in a fresh session; the tutor transcript arrives + # via history_references → context.history_context, not conversation_context_text. + history_context = str(context.history_context or "").strip() + if not history_context: + history_context = str( + context.metadata.get("conversation_context_text", "") or "" + ).strip() enabled_tools = set( self.manifest.tools_used if context.enabled_tools is None else context.enabled_tools ) @@ -213,6 +219,7 @@ async def _run_answer_now( join_chunks, labeled_block, load_answer_now_prompts, + answer_now_system_prompt, make_skip_notice, stream_synthesis, ) @@ -228,7 +235,7 @@ async def _run_answer_now( question_type = str(overrides.get("question_type", "") or "auto") prompts = load_answer_now_prompts("question", context.language) - system_prompt = str(prompts.get("system", "")).strip() + system_prompt = answer_now_system_prompt("question", context.language) user_prompt = str(prompts.get("user_template", "")).format( topic=topic, num_questions=num_questions, diff --git a/deeptutor/capabilities/deep_research.py b/deeptutor/capabilities/deep_research.py index 4b0468195..90bcc66e9 100644 --- a/deeptutor/capabilities/deep_research.py +++ b/deeptutor/capabilities/deep_research.py @@ -410,6 +410,7 @@ async def _run_answer_now( join_chunks, labeled_block, load_answer_now_prompts, + answer_now_system_prompt, make_skip_notice, stream_synthesis, ) @@ -419,7 +420,7 @@ async def _run_answer_now( trace_summary = format_trace_summary(payload.get("events"), language=context.language) prompts = load_answer_now_prompts("research", context.language) - system_prompt = str(prompts.get("system", "")).strip() + system_prompt = answer_now_system_prompt("research", context.language) user_prompt = str(prompts.get("user_template", "")).format( original=original, current_draft=labeled_block("Current Draft", partial), diff --git a/deeptutor/capabilities/deep_solve.py b/deeptutor/capabilities/deep_solve.py index 891838250..185b535eb 100644 --- a/deeptutor/capabilities/deep_solve.py +++ b/deeptutor/capabilities/deep_solve.py @@ -310,6 +310,7 @@ async def _run_answer_now( join_chunks, labeled_block, load_answer_now_prompts, + answer_now_system_prompt, make_skip_notice, stream_synthesis, ) @@ -319,7 +320,7 @@ async def _run_answer_now( trace_summary = format_trace_summary(payload.get("events"), language=context.language) prompts = load_answer_now_prompts("solve", context.language) - system_prompt = str(prompts.get("system", "")).strip() + system_prompt = answer_now_system_prompt("solve", context.language) user_prompt = str(prompts.get("user_template", "")).format( original=original, current_draft=labeled_block("Current Draft", partial), diff --git a/deeptutor/capabilities/visualize.py b/deeptutor/capabilities/visualize.py index 845b56f52..ed0b755b8 100644 --- a/deeptutor/capabilities/visualize.py +++ b/deeptutor/capabilities/visualize.py @@ -202,6 +202,7 @@ async def _run_answer_now( join_chunks, labeled_block, load_answer_now_prompts, + answer_now_system_prompt, make_skip_notice, stream_synthesis, ) @@ -215,7 +216,7 @@ async def _run_answer_now( ) prompts = load_answer_now_prompts("visualize", context.language) - system_prompt = str(prompts.get("system", "")).strip() + system_prompt = answer_now_system_prompt("visualize", context.language) user_prompt = str(prompts.get("user_template", "")).format( original=original, render_mode=render_mode, diff --git a/deeptutor/services/memory/service.py b/deeptutor/services/memory/service.py index a5530e779..9cc3e2fa3 100644 --- a/deeptutor/services/memory/service.py +++ b/deeptutor/services/memory/service.py @@ -342,7 +342,8 @@ 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" f"[当前画像]\n{current or '(empty)'}\n\n" f"[新增材料]\n{source}", ) @@ -352,7 +353,9 @@ def _profile_prompts(current: str, source: str, zh: bool) -> tuple[str, str]: f"If nothing should change, return exactly {_NO_CHANGE}.", "Rewrite the user profile if needed. Suggested sections:\n" "## Identity\n## Learning Style\n## Knowledge Level\n## Preferences\n\n" - "Rules: keep it short, remove stale items, no transient chatter.\n\n" + "Rules: keep it short, remove stale items, no transient chatter. " + "Never include psychological counseling, emotional wellbeing sessions, " + "stress/anxiety notes, or private feelings.\n\n" f"[Current profile]\n{current or '(empty)'}\n\n" f"[New material]\n{source}", ) @@ -365,7 +368,8 @@ 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" f"[当前摘要]\n{current or '(empty)'}\n\n" f"[新增材料]\n{source}", ) @@ -375,7 +379,9 @@ def _summary_prompts(current: str, source: str, zh: bool) -> tuple[str, str]: f"If nothing should change, return exactly {_NO_CHANGE}.", "Rewrite the learning summary if needed. Suggested sections:\n" "## Current Focus\n## Accomplishments\n## Open Questions\n\n" - "Rules: keep it short, remove completed/stale items.\n\n" + "Rules: keep it short, remove completed/stale items. " + "Never include psychological counseling or emotional wellbeing sessions — " + "academic learning progress only.\n\n" f"[Current summary]\n{current or '(empty)'}\n\n" f"[New material]\n{source}", ) diff --git a/deeptutor/services/path_service.py b/deeptutor/services/path_service.py index 30ea77303..4813ff98d 100644 --- a/deeptutor/services/path_service.py +++ b/deeptutor/services/path_service.py @@ -22,6 +22,7 @@ └── _detached_code_execution/ """ +import os from pathlib import Path from typing import Literal, cast @@ -78,6 +79,18 @@ class PathService: def __init__(self, workspace_root: Path | None = None): self._project_root = Path(__file__).resolve().parent.parent.parent self._uses_default_workspace_root = workspace_root is None + # Allow deployments (e.g. dynamic_router user containers) to relocate + # DeepTutor runtime data under a mounted workspace directory. + # + # Example: + # DEEPTUTOR_WORKSPACE_ROOT=/workspace/deeptutor + # + # This keeps DeepTutor fully self-contained under /workspace/deeptutor + # without polluting other paths mounted at /workspace. + env_value = str(os.getenv("DEEPTUTOR_WORKSPACE_ROOT", "")).strip() + if env_value: + workspace_root = Path(env_value) + self._workspace_root = (workspace_root or self._project_root / "data").resolve() self._user_data_dir = (self._workspace_root / "user").resolve() diff --git a/deeptutor/services/prompt/__init__.py b/deeptutor/services/prompt/__init__.py index 579889d62..95ccf2379 100644 --- a/deeptutor/services/prompt/__init__.py +++ b/deeptutor/services/prompt/__init__.py @@ -24,10 +24,12 @@ normalize_language, ) from .manager import PromptManager, get_prompt_manager +from .oxca_brand import apply_student_guardrail __all__ = [ "PromptManager", "append_language_directive", + "apply_student_guardrail", "get_prompt_manager", "language_directive", "language_label", diff --git a/deeptutor/services/prompt/oxca_brand.py b/deeptutor/services/prompt/oxca_brand.py new file mode 100644 index 000000000..22eb05544 --- /dev/null +++ b/deeptutor/services/prompt/oxca_brand.py @@ -0,0 +1,31 @@ +"""OxCa white-label guardrails for student-facing LLM prompts.""" + +from __future__ import annotations + +GUARDRAIL_EN = """ +Branding rules (strict): +- You are OxCa's AI learning assistant (牛剑通途), helping the student with their studies. +- Never mention DeepTutor, HKU, the Data Intelligence Lab, or any underlying AI platform, engine, or vendor to the student. +- Never describe internal pipelines, tool orchestration, or backend systems. +- If asked what you are, say you are OxCa's AI tutor / learning assistant. +""" + +GUARDRAIL_ZH = """ +品牌规则(严格遵守): +- 你是 OxCa(牛剑通途)的 AI 学习助手,帮助学生学习。 +- 面向学生时不要提及 DeepTutor、香港大学、数据智能实验室或任何底层 AI 平台、引擎、供应商。 +- 不要描述内部流水线、工具编排或后台系统。 +- 若被问身份,说明你是 OxCa 的 AI 导师 / 学习助手。 +""" + + +def apply_student_guardrail(system_prompt: str, language: str) -> str: + """Append OxCa branding rules to a student-facing system prompt.""" + base = (system_prompt or "").strip() + guard = GUARDRAIL_ZH if str(language).lower().startswith("zh") else GUARDRAIL_EN + if not base: + return guard.strip() + return f"{base}\n{guard.strip()}" + + +__all__ = ["GUARDRAIL_EN", "GUARDRAIL_ZH", "apply_student_guardrail"] diff --git a/deeptutor/services/session/sqlite_store.py b/deeptutor/services/session/sqlite_store.py index c18682a9d..a2c60324b 100644 --- a/deeptutor/services/session/sqlite_store.py +++ b/deeptutor/services/session/sqlite_store.py @@ -863,6 +863,12 @@ def _upsert_notebook_entries_sync(self, session_id: str, items: list[dict[str, A created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '', ?, ?) ON CONFLICT(session_id, question_id) DO UPDATE SET + question = excluded.question, + question_type = excluded.question_type, + options_json = excluded.options_json, + correct_answer = excluded.correct_answer, + explanation = excluded.explanation, + difficulty = excluded.difficulty, user_answer = excluded.user_answer, is_correct = excluded.is_correct, updated_at = excluded.updated_at diff --git a/deeptutor/services/session/turn_runtime.py b/deeptutor/services/session/turn_runtime.py index b703bd657..ce32f3282 100644 --- a/deeptutor/services/session/turn_runtime.py +++ b/deeptutor/services/session/turn_runtime.py @@ -24,6 +24,9 @@ MemoryReference = Literal["summary", "profile"] +# Wellbeing / focus-coach turns must not update shared learning memory files. +_WELLBEING_MEMORY_SKIP_SKILLS = frozenset({"focus-coach"}) + def _should_capture_assistant_content(event: StreamEvent) -> bool: if event.type != StreamEventType.CONTENT: @@ -1063,7 +1066,9 @@ async def _emit_context_event(event: StreamEvent) -> None: events=assistant_events, ) await self.store.update_turn_status(turn_id, "completed") - if not is_regenerate: + if not is_regenerate and not ( + set(resolved_skills) & _WELLBEING_MEMORY_SKIP_SKILLS + ): try: await memory_service.refresh_from_turn( user_message=raw_user_content, diff --git a/deeptutor/services/setup/init.py b/deeptutor/services/setup/init.py index 86b01a6c6..b231f55f3 100644 --- a/deeptutor/services/setup/init.py +++ b/deeptutor/services/setup/init.py @@ -19,7 +19,7 @@ DEFAULT_INTERFACE_SETTINGS = { "theme": "light", "language": "en", - "sidebar_description": "✨ Data Intelligence Lab @ HKU", + "sidebar_description": "✨ OxCa · 牛剑通途", "sidebar_nav_order": { "start": ["/", "/history", "/knowledge", "/notebook"], "learnResearch": ["/question", "/solver", "/research", "/co_writer"], @@ -46,7 +46,13 @@ }, "tools": { "run_code": { - "allowed_roots": ["./data/user"], + # Loose mode for shared deployments: allow the whole DeepTutor data root + # under the mounted workspace (dynamic_router user containers mount /workspace). + # This keeps DeepTutor self-contained under /workspace/deeptutor without + # polluting /workspace/{agents,skills,mcps,envs}. + # + # Note: these defaults are only written when settings files are missing. + "allowed_roots": ["/workspace/deeptutor", "./data/user", "./data"], }, "web_search": { "enabled": True, diff --git a/deeptutor_compat/__init__.py b/deeptutor_compat/__init__.py new file mode 100644 index 000000000..fe16459e0 --- /dev/null +++ b/deeptutor_compat/__init__.py @@ -0,0 +1,2 @@ +__all__ = [] + diff --git a/deeptutor_compat/gateway.py b/deeptutor_compat/gateway.py new file mode 100644 index 000000000..4f6a93c65 --- /dev/null +++ b/deeptutor_compat/gateway.py @@ -0,0 +1,171 @@ +import os +from typing import Iterable + +import httpx +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import StreamingResponse + + +def _int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except (TypeError, ValueError): + return default + + +BACKEND_PORT = _int("BACKEND_PORT", 8001) +FRONTEND_PORT = _int("FRONTEND_PORT", 3782) + + +def create_gateway_app() -> FastAPI: + """ + A thin compat gateway that makes DeepTutor look like the agent_gateway + contract expected by dynamic_router: + - listens on a single port (dynamic_router uses CONTAINER_PORT, default 3000) + - GET /health for readiness + - proxies /api/* and /docs, /openapi.json to backend + - proxies everything else to the Next.js frontend + - proxies WebSocket /api/v1/ws to backend + """ + + gateway_app = FastAPI(title="OxCa Compat Gateway", docs_url=None, redoc_url=None) + + @gateway_app.get("/health") + async def health(): + return {"status": "ok"} + + def _backend_url(path: str, query: str | None) -> str: + url = f"http://127.0.0.1:{BACKEND_PORT}{path}" + if query: + url += f"?{query}" + return url + + def _frontend_url(path: str, query: str | None) -> str: + url = f"http://127.0.0.1:{FRONTEND_PORT}{path}" + if query: + url += f"?{query}" + return url + + def _copy_headers(src: Iterable[tuple[str, str]]) -> dict[str, str]: + # Keep cookies/auth, but strip hop-by-hop headers. + skip = { + "host", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + } + out: dict[str, str] = {} + for k, v in src: + if k.lower() not in skip: + out[k] = v + return out + + @gateway_app.websocket("/api/v1/ws") + async def ws_proxy(ws: WebSocket): + await ws.accept() + upstream = f"ws://127.0.0.1:{BACKEND_PORT}/api/v1/ws" + + # Prefer websockets if available (uvicorn[standard] installs it). + import websockets # type: ignore + + try: + async with websockets.connect( + upstream, + extra_headers=_copy_headers(ws.headers.raw), + max_size=None, + ) as upstream_ws: + + async def _client_to_upstream(): + try: + while True: + msg = await ws.receive() + if "text" in msg and msg["text"] is not None: + await upstream_ws.send(msg["text"]) + elif "bytes" in msg and msg["bytes"] is not None: + await upstream_ws.send(msg["bytes"]) + else: + break + except WebSocketDisconnect: + pass + except RuntimeError: + pass + + async def _upstream_to_client(): + try: + async for msg in upstream_ws: + if isinstance(msg, bytes): + await ws.send_bytes(msg) + else: + await ws.send_text(msg) + except RuntimeError: + pass + + import asyncio + + await asyncio.gather(_client_to_upstream(), _upstream_to_client()) + finally: + try: + await ws.close() + except RuntimeError: + pass + + @gateway_app.api_route( + "/{path:path}", + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"], + ) + async def proxy_all(request: Request, path: str): + # Route selection: + # - backend: /api/*, /docs, /openapi.json (and any future backend root assets) + # - frontend: everything else + full_path = "/" + path + to_backend = ( + full_path.startswith("/api/") + or full_path == "/api" + or full_path.startswith("/docs") + or full_path == "/openapi.json" + ) + + target = ( + _backend_url(full_path, request.url.query) if to_backend else _frontend_url(full_path, request.url.query) + ) + + headers = _copy_headers(request.headers.items()) + body = await request.body() + + timeout = httpx.Timeout(300.0, connect=10.0) + async with httpx.AsyncClient(timeout=timeout) as client: + upstream = await client.send( + client.build_request( + method=request.method, + url=target, + headers=headers, + content=body, + ), + stream=True, + ) + + resp_headers = dict(upstream.headers) + for h in ("transfer-encoding", "connection", "keep-alive"): + resp_headers.pop(h, None) + + async def stream_body(): + async for chunk in upstream.aiter_bytes(): + yield chunk + await upstream.aclose() + + return StreamingResponse( + content=stream_body(), + status_code=upstream.status_code, + headers=resp_headers, + ) + + return gateway_app + + +app = create_gateway_app() + diff --git a/web/app/(admin)/admin/users/page.tsx b/web/app/(admin)/admin/users/page.tsx index aa996f3f2..bb243f2b5 100644 --- a/web/app/(admin)/admin/users/page.tsx +++ b/web/app/(admin)/admin/users/page.tsx @@ -330,7 +330,7 @@ export default function AdminUsersPage() {
- DeepTutor Admin · User Management + OxCa Admin · User Management
diff --git a/web/app/(auth)/login/page.tsx b/web/app/(auth)/login/page.tsx index 2a1244383..9c0933b71 100644 --- a/web/app/(auth)/login/page.tsx +++ b/web/app/(auth)/login/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { login, fetchAuthStatus, checkIsFirstUser } from "@/lib/auth"; +import { PRODUCT_NAME, PRODUCT_TAGLINE } from "@/lib/product-brand"; function LoginPageContent() { const router = useRouter(); @@ -51,7 +52,7 @@ function LoginPageContent() { {/* Logo / Title */}Sign in to your account @@ -149,7 +150,7 @@ function LoginPageContent() {
- DeepTutor · Agent-Native Learning + {PRODUCT_TAGLINE}
Create your account @@ -180,7 +181,7 @@ export default function RegisterPage() {
- DeepTutor · Agent-Native Learning + {PRODUCT_TAGLINE}
Write, revise, and organize learning content with AI.
{t( - "Pick Auto to let DeepTutor decide, or choose specific skills to apply.", + "Pick Auto to let OxCa decide, or choose specific skills to apply.", )}
diff --git a/web/components/sidebar/SidebarShell.tsx b/web/components/sidebar/SidebarShell.tsx index 305dab4a9..1d4a7e6c7 100644 --- a/web/components/sidebar/SidebarShell.tsx +++ b/web/components/sidebar/SidebarShell.tsx @@ -8,7 +8,6 @@ import { useAppShell } from "@/context/AppShellContext"; import { BookOpen, Bot, - Github, LayoutGrid, Library, MessageSquare, @@ -25,6 +24,7 @@ import { TutorBotRecent } from "@/components/sidebar/TutorBotRecent"; import { VersionBadge } from "@/components/sidebar/VersionBadge"; import type { SessionSummary } from "@/lib/session-api"; import { Tooltip } from "@/components/ui/Tooltip"; +import { PRODUCT_NAME } from "@/lib/product-brand"; interface NavEntry { href: string; @@ -71,7 +71,6 @@ const SECONDARY_NAV: NavEntry[] = [ { href: "/settings", label: "Settings", icon: Settings }, ]; const DEFAULT_SESSION_VIEWPORT_CLASS_NAME = "max-h-[112px]"; -const GITHUB_REPO_URL = "https://github.com/HKUDS/DeepTutor"; interface SidebarShellProps { sessions?: SessionSummary[]; @@ -120,12 +119,12 @@ export function SidebarShell({